Release v0.2.0: Task comments, recurring calendar, system tray, app branding

New features:
- Task comments with date-stamped entries and last-comment summary
- Recurring tasks expanded on calendar (daily/weekly/monthly/yearly)
- System tray mode replacing CMD window (Windows/macOS/Linux)
- Ironpad logo as exe icon, tray icon, favicon, and header logo

Technical changes:
- Backend restructured for dual-mode: dev (API-only) / prod (tray + server)
- tray-item crate for cross-platform tray, winresource for icon embedding
- Calendar view refactored with CalendarEntry interface for recurring merging
- Added CHANGELOG.md, build-local.ps1, version bumped to 0.2.0

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
skepsismusic
2026-02-16 13:48:54 +01:00
parent b150a243fd
commit 781ea28097
29 changed files with 1735 additions and 219 deletions

3
.gitignore vendored
View File

@@ -41,5 +41,8 @@ data/projects/*/
# === Stray root lock file (frontend/package-lock.json is kept for CI) === # === Stray root lock file (frontend/package-lock.json is kept for CI) ===
/package-lock.json /package-lock.json
# === Local build output ===
release/
# === Generated images (article assets, not source) === # === Generated images (article assets, not source) ===
/assets/ /assets/

39
CHANGELOG.md Normal file
View File

@@ -0,0 +1,39 @@
# Changelog
All notable changes to Ironpad are documented here.
## [0.2.0] - 2026-02-16
### Added
- **Task comments** -- date-stamped comment entries per task, stored as YAML in frontmatter. Last comment shown as summary in task list and dashboard cards. Add/delete via API and UI.
- **Recurring tasks on calendar** -- tasks with daily/weekly/monthly/yearly recurrence now appear on the calendar even without an explicit `due_date`. Occurrences are computed from the anchor date (`due_date` or `created`) and `recurrence_interval`. Recurring entries show with a dashed border and recurrence icon to distinguish from regular due-date tasks.
- **System tray mode** -- production binary runs in the system tray instead of a console window. Tray menu with "Open in Browser" and "Quit". No CMD window on Windows in release builds (`windows_subsystem = "windows"`). Server runs on a background thread with the tray event loop on the main thread for cross-platform safety.
- **App icon and branding** -- Ironpad logo embedded in the Windows executable (Explorer icon + tray icon) via `winresource`. Favicon and logo added to the web UI (browser tab + top bar header).
- **Local build script** -- `build-local.ps1` for building a testable release package locally.
### Changed
- Backend `main.rs` restructured for dual-mode operation: development mode runs the server directly (no tray), production mode runs server on background thread with tray on main thread.
- Calendar view refactored to use `CalendarEntry` interface that merges regular due-date tasks with computed recurring occurrences.
### Dependencies
- Added `tray-item = "0.10"` for cross-platform system tray support.
- Added `windows-sys = "0.52"` (Windows only) for loading the embedded icon resource.
- Added `winresource = "0.1"` (Windows build dependency) for embedding the icon in the .exe.
---
## [0.1.0] - 2025-12-01
### Added
- Initial release of Ironpad -- local-first, file-based project and knowledge management.
- **Backend**: Rust/Axum API server with dynamic port (3000-3010), WebSocket real-time sync, file watcher, Git auto-commit (60s batching), ripgrep search.
- **Frontend**: Vue 3 SPA with Milkdown WYSIWYG editor, dark/light theme, Pinia state management.
- **File-based tasks**: each task stored as a markdown file with YAML frontmatter (title, completed, section, priority, due_date, tags, subtasks, recurrence).
- **Split-panel task view**: task list with active/backlog/completed sections, detail editor with markdown, due date picker, tag system, subtasks, recurrence picker.
- **Calendar view**: month grid showing tasks by due date with color-coded urgency and daily note indicators.
- **Dashboard**: cross-project home page with active task summaries per project.
- **Daily notes**: date-based notes with templates.
- **Git panel**: commit history with diffs, working directory changes, push/fetch with ahead/behind indicators.
- **Project notes**: split-panel notes view per project.
- **Search**: Ctrl+K search panel with ripgrep-powered full-text search.
- Cross-platform builds (Windows, macOS, Linux) via GitHub Actions.

View File

@@ -8,13 +8,13 @@
![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg) ![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
![Rust](https://img.shields.io/badge/rust-1.70%2B-orange) ![Rust](https://img.shields.io/badge/rust-1.70%2B-orange)
![Version](https://img.shields.io/badge/version-0.1.0-green) ![Version](https://img.shields.io/badge/version-0.2.0-green)
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. 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.
![Ironpad Screenshot](docs/screenshot.jpg) ![Ironpad Screenshot](docs/screenshot.jpg)
> **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). > **v0.2.0** -- Task comments, recurring tasks on calendar, system tray mode, and app branding. See [CHANGELOG.md](CHANGELOG.md) for details.
--- ---
@@ -24,14 +24,15 @@ Ironpad stores all your notes, projects, and tasks as plain Markdown files. No c
- **Local-first** -- Works fully offline, no internet required - **Local-first** -- Works fully offline, no internet required
- **Git integration** -- Automatic version control with 60-second commit batching, full diff viewer, push/fetch - **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 - **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 - **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, recurrence, and comments
- **Calendar view** -- Month grid showing tasks by due date with color-coded urgency - **Calendar view** -- Month grid showing tasks by due date with color-coded urgency; recurring tasks automatically expanded across the month
- **Dashboard** -- Cross-project overview with active task summaries - **Dashboard** -- Cross-project overview with active task summaries
- **Daily notes** -- Quick capture with templates for daily journaling - **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 - **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 - **External editing** -- Full support for VS Code, Obsidian, Vim, or any text editor
- **Search** -- ripgrep-powered full-text search across all files (Ctrl+K) - **Search** -- ripgrep-powered full-text search across all files (Ctrl+K)
- **Dark theme** -- Beautiful dark UI by default with light mode toggle - **Dark theme** -- Beautiful dark UI by default with light mode toggle
- **System tray** -- Runs quietly in the system tray (Windows, macOS, Linux); no console window in release builds
- **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup - **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup
## Quick Start ## Quick Start
@@ -86,6 +87,9 @@ Open http://localhost:5173 in your browser.
Ironpad is under active development. Here's what's planned: Ironpad is under active development. Here's what's planned:
- [x] Task comments and activity summary
- [x] Recurring tasks on calendar (daily/weekly/monthly/yearly expansion)
- [x] System tray mode (Windows, macOS, Linux)
- [ ] UI polish and animations - [ ] UI polish and animations
- [ ] Tag extraction and filtering across projects - [ ] Tag extraction and filtering across projects
- [ ] Backlinks between notes - [ ] Backlinks between notes
@@ -93,7 +97,6 @@ Ironpad is under active development. Here's what's planned:
- [ ] Export to PDF / HTML - [ ] Export to PDF / HTML
- [ ] Custom themes - [ ] Custom themes
- [ ] Global hotkey (Ctrl+Shift+Space) - [ ] Global hotkey (Ctrl+Shift+Space)
- [ ] System tray mode
- [ ] Kanban board view for tasks - [ ] Kanban board view for tasks
See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status. See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status.

View File

@@ -1,28 +1,30 @@
# Ironpad Roadmap # Ironpad Roadmap
## Release 0.2.0 (Next) ## Release 0.2.0 (Current)
### Planned Features ### Features
#### 1. Task comments & activity summary #### 1. Task comments & activity summary
- **Comment section** per task with date-stamped entries - **Comment section** per task with date-stamped entries
- Store comments (e.g. in task file as YAML sequence or dedicated section) - Store comments as YAML sequence in task frontmatter
- **Last comment as summary** — show the most recent comment/activity in the main task list and dashboard to indicate last action or status - **Last comment as summary** -- most recent comment shown in task list and dashboard cards
- Enables quick status updates without editing the full description - Add/delete comments via API and UI, newest-first display with relative timestamps
#### 2. Recurring tasks on the calendar #### 2. Recurring tasks on the calendar
- **Bug/feature gap**: Tasks with daily/weekly recurrence but no explicit `due_date` currently do not appear on the calendar (calendar only shows tasks with `task.due_date`) - Tasks with daily/weekly recurrence now appear on the calendar (previously required explicit `due_date`)
- **Change**: Expand recurring tasks into the calendar for the visible month: - Recurring tasks expanded into the visible month grid (daily/weekly/monthly/yearly)
- **Daily** — show on every day in the month (or cap at reasonable limit) - Anchor date: `due_date` if set, otherwise `created`; respects `recurrence_interval`
- **Weekly** — show on the matching weekday(s) in the month - Recurring occurrences shown with dashed border and recurrence icon
- **Monthly** — show on the day-of-month if set, else treat as “floating”
- Requires frontend logic to compute occurrences from `recurrence`, `recurrence_interval`, and optionally `due_date` / `created`
#### 3. System tray mode #### 3. System tray mode
- **Replace CMD window** with a system tray icon (Windows, macOS, Linux) - System tray icon replaces CMD window (Windows, macOS, Linux)
- Tray menu: **Open in Browser** | **Quit** - Tray menu: **Open in Browser** | **Quit**
- No console window on Windows in release builds - No console window on Windows in release builds
- Implementation doc: [docs/system-tray-implementation.md](docs/system-tray-implementation.md) - Server runs on background thread; tray event loop on main thread (cross-platform safe)
#### 4. App branding
- Ironpad logo as system tray icon and Windows exe icon
- Favicon and logo in the web UI (browser tab + header)
--- ---
@@ -31,18 +33,18 @@
Ideas that fit the current architecture and local-first design: Ideas that fit the current architecture and local-first design:
### High fit (0.3.x) ### High fit (0.3.x)
- **Calendar drag-and-drop** reschedule tasks by dragging onto a new date (already listed in ai-context) - **Calendar drag-and-drop** -- reschedule tasks by dragging onto a new date
- **Week / day calendar views** alternative to month view for denser task planning - **Week / day calendar views** -- alternative to month view for denser task planning
- **Sort task list by due date / priority** alongside current created-date sorting - **Sort task list by due date / priority** -- alongside current created-date sorting
- **Overdue indicator** clearer overdue badge or count in sidebar and dashboard - **Overdue indicator** -- clearer overdue badge or count in sidebar and dashboard
### Medium fit (0.4.x) ### Medium fit (0.4.x)
- **Quick-add task** global or dashboard shortcut to create a task without opening a project - **Quick-add task** -- global or dashboard shortcut to create a task without opening a project
- **Bulk actions** complete multiple tasks, move section, add/remove tags in one go - **Bulk actions** -- complete multiple tasks, move section, add/remove tags in one go
- **Task templates** create tasks from predefined templates (e.g. Meeting prep, Review) - **Task templates** -- create tasks from predefined templates (e.g. "Meeting prep", "Review")
- **Tag extraction and cross-project filtering** surface and filter by tags across all projects - **Tag extraction and cross-project filtering** -- surface and filter by tags across all projects
### Longer term (Phase 6+) ### Longer term
- UI polish and subtle animations - UI polish and subtle animations
- Responsive sidebar / mobile-friendly layout - Responsive sidebar / mobile-friendly layout
- Global hotkey (e.g. Ctrl+Shift+Space) - Global hotkey (e.g. Ctrl+Shift+Space)
@@ -56,7 +58,7 @@ Ideas that fit the current architecture and local-first design:
## Version history ## Version history
| Version | Status | Notes | | Version | Status | Date | Notes |
|---------|---------|----------------------------------------------------| |---------|----------|------------|----------------------------------------------------------|
| 0.1.0 | Current | First public release, core features in place | | 0.1.0 | Released | 2025-12-01 | First public release, core features in place |
| 0.2.0 | Planned | Comments, recurring tasks on calendar, system tray | | 0.2.0 | Current | 2026-02-16 | Comments, recurring calendar, system tray, app branding |

View File

@@ -67,7 +67,7 @@ ironpad/
## Implemented Features ## Implemented Features
### Backend ### Backend
- API-only server (no frontend serving, no browser auto-open) - **Dual-mode server**: API-only in development; frontend-serving + system tray in production
- Dynamic port (3000-3010) - Dynamic port (3000-3010)
- Notes CRUD with atomic writes - Notes CRUD with atomic writes
- Frontmatter auto-management - Frontmatter auto-management
@@ -79,12 +79,13 @@ ironpad/
- Git remote info (ahead/behind tracking), fetch support - Git remote info (ahead/behind tracking), fetch support
- Projects API with notes management - Projects API with notes management
- **File-based Tasks API** — each task is a markdown file with frontmatter - **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 - Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval, comments
- Rich text descriptions with markdown support - Rich text descriptions with markdown support
- Sorted by created date (stable ordering) - Sorted by created date (stable ordering)
- **Subtasks** — tasks with `parent_id` link to a parent task - **Subtasks** — tasks with `parent_id` link to a parent task
- **Tags** — YAML sequence in frontmatter, per-task labels for filtering - **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 - **Recurring tasks** — when completing a recurring task, auto-creates next instance with advanced due date
- **Comments** — date-stamped comment entries stored as YAML sequence in frontmatter; last comment shown as summary in list/dashboard
- Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`) - Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`)
- Assets API (upload + serve) - Assets API (upload + serve)
@@ -113,9 +114,14 @@ ironpad/
- **Tag filter bar** — click tags to filter task list - **Tag filter bar** — click tags to filter task list
- **Subtasks** — expandable subtasks under parent tasks, add subtask inline - **Subtasks** — expandable subtasks under parent tasks, add subtask inline
- **Recurrence picker** — set daily/weekly/monthly/yearly recurrence - **Recurrence picker** — set daily/weekly/monthly/yearly recurrence
- **Task comments** — date-stamped comments section in task detail, newest first, add/delete
- **Last comment summary** — most recent comment shown in task list and dashboard cards
- Inline title editing (double-click) - Inline title editing (double-click)
- **Calendar view** — month grid showing tasks by due date - **Calendar view** — month grid showing tasks by due date + recurring task expansion
- Tasks with due dates plotted on calendar cells - Tasks with due dates plotted on calendar cells
- **Recurring tasks expanded** — daily/weekly/monthly/yearly tasks shown on computed occurrences
- Recurring occurrences use anchor date (`due_date` or `created`), respect `recurrence_interval`
- Recurring entries shown with dashed border and ↻ icon to distinguish from regular tasks
- Daily notes shown as blue dots - Daily notes shown as blue dots
- Color-coded urgency (overdue, today, soon) - Color-coded urgency (overdue, today, soon)
- Month navigation + Today button - Month navigation + Today button
@@ -150,6 +156,8 @@ GET/POST /api/projects/:id/tasks
GET/PUT/DEL /api/projects/:id/tasks/:task_id GET/PUT/DEL /api/projects/:id/tasks/:task_id
PUT /api/projects/:id/tasks/:task_id/toggle PUT /api/projects/:id/tasks/:task_id/toggle
PUT /api/projects/:id/tasks/:task_id/meta PUT /api/projects/:id/tasks/:task_id/meta
POST /api/projects/:id/tasks/:task_id/comments
DELETE /api/projects/:id/tasks/:task_id/comments/:index
GET /api/tasks # All tasks across projects GET /api/tasks # All tasks across projects
GET /api/daily GET /api/daily
@@ -224,12 +232,25 @@ WS /ws
--- ---
## Not Yet Implemented (Phase 6+) ## Implemented in Phase 6
- **Recurring tasks on calendar** — frontend expands daily/weekly/monthly/yearly tasks into the visible month grid
- Anchor date: `due_date` if set, otherwise `created`; respects `recurrence_interval`
- Deduplicates against regular due-date entries; visual indicator (dashed border, ↻ icon)
- **System tray mode** — production binary runs in system tray (Windows, macOS, Linux)
- `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` hides console on Windows
- Server on background thread, tray event loop on main thread (cross-platform safe)
- Tray menu: "Open in Browser" / "Quit"
- Uses `tray-item` crate with platform-specific icon loading (`windows-sys` on Windows)
- Development mode unchanged (no tray, API-only)
---
## Not Yet Implemented (Phase 7+)
- UI polish and animations - UI polish and animations
- Responsive sidebar - Responsive sidebar
- Global hotkey (Ctrl+Shift+Space) - Global hotkey (Ctrl+Shift+Space)
- System tray mode
- Backlinks between notes - Backlinks between notes
- Graph view - Graph view
- Export (PDF / HTML) - Export (PDF / HTML)
@@ -262,12 +283,14 @@ WS /ws
| Project note ID | `{slug}-index` format | | Project note ID | `{slug}-index` format |
| Task storage | Individual .md files in `tasks/` folder | | Task storage | Individual .md files in `tasks/` folder |
| List sorting | By created date (stable, not affected by edits) | | List sorting | By created date (stable, not affected by edits) |
| Backend mode | API-only (no frontend serving) | | Backend mode | API-only (dev); frontend-serving + system tray (production) |
| Theme | Dark by default, toggle to light, persists to localStorage | | Theme | Dark by default, toggle to light, persists to localStorage |
| Tags | YAML sequence in frontmatter, project-scoped filtering | | Tags | YAML sequence in frontmatter, project-scoped filtering |
| Subtasks | Separate task files with `parent_id` field linking to parent | | Subtasks | Separate task files with `parent_id` field linking to parent |
| Recurring tasks | On completion, backend auto-creates next instance with advanced due date | | Recurring tasks | On completion, backend auto-creates next instance with advanced due date |
| Calendar | Pure frontend month grid, tasks filtered by `due_date` presence | | Calendar | Pure frontend month grid, tasks by `due_date` + recurring expansion |
| System tray | `tray-item` crate; main thread tray, background thread server; `windows-sys` for icon on Windows |
| Task comments | YAML sequence in frontmatter, date-stamped, last comment as list/dashboard summary |
| Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary | | Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary |
--- ---

628
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironpad" name = "ironpad"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@@ -47,3 +47,14 @@ uuid = { version = "1.0", features = ["v4"] }
# Utilities # Utilities
lazy_static = "1.4" lazy_static = "1.4"
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
# System tray (production mode)
tray-item = "0.10"
# Windows icon loading (for tray icon)
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader"] }
# Build dependencies (Windows icon embedding)
[target.'cfg(target_os = "windows")'.build-dependencies]
winresource = "0.1"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
backend/assets/ironpad.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

10
backend/build.rs Normal file
View File

@@ -0,0 +1,10 @@
fn main() {
// On Windows, embed the application icon into the .exe
// This sets both the Explorer icon and makes it available as a resource
#[cfg(target_os = "windows")]
{
let mut res = winresource::WindowsResource::new();
res.set_icon("assets/ironpad.ico");
res.compile().expect("Failed to compile Windows resources");
}
}

View File

@@ -1,3 +1,6 @@
// Hide console window on Windows in release builds (production mode)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
@@ -26,17 +29,29 @@ async fn find_available_port() -> (TcpListener, u16) {
panic!("No available ports in range 30003010"); panic!("No available ports in range 30003010");
} }
#[tokio::main] fn main() {
async fn main() {
// Logging
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
// Resolve data directory (production vs development mode)
config::init_data_dir(); config::init_data_dir();
// Find port and bind (listener kept alive to avoid race condition) if config::is_production() {
run_with_tray();
} else {
// Development mode: normal tokio runtime, no tray
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(None));
}
}
/// Start the Axum server. In tray mode, sends the bound port through `port_tx`
/// before entering the serve loop.
async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
let (listener, port) = find_available_port().await; let (listener, port) = find_available_port().await;
// Notify tray thread of the bound port
if let Some(tx) = port_tx {
let _ = tx.send(port);
}
// WebSocket state (shared across handlers) // WebSocket state (shared across handlers)
let ws_state = Arc::new(websocket::WsState::new()); let ws_state = Arc::new(websocket::WsState::new());
@@ -94,7 +109,6 @@ async fn main() {
.layer(cors); .layer(cors);
// Check for embedded frontend (production mode) // Check for embedded frontend (production mode)
// Resolve relative to the executable's directory, not the working directory
let has_frontend = config::is_production(); let has_frontend = config::is_production();
if has_frontend { if has_frontend {
@@ -116,23 +130,110 @@ async fn main() {
} }
// Start server // Start server
info!("🚀 Ironpad running on http://localhost:{port}"); 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"); axum::serve(listener, app).await.expect("Server failed");
} }
// ---------------------------------------------------------------------------
// System tray (production mode)
// ---------------------------------------------------------------------------
/// Build a platform-appropriate tray icon.
///
/// On Windows the Ironpad icon is embedded in the .exe via winresource (build.rs).
/// We load it with LoadIconW using the resource ID assigned by winresource.
#[cfg(target_os = "windows")]
fn tray_icon() -> tray_item::IconSource {
let hicon = unsafe {
// winresource embeds the icon at resource ID 1.
// GetModuleHandleW(null) = current exe, MAKEINTRESOURCE(1) = 1 as PCWSTR.
let hinstance =
windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(std::ptr::null());
windows_sys::Win32::UI::WindowsAndMessaging::LoadIconW(hinstance, 1 as *const u16)
};
tray_item::IconSource::RawIcon(hicon)
}
#[cfg(target_os = "macos")]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("")
}
#[cfg(target_os = "linux")]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("application-x-executable")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("")
}
/// Production mode: run the server on a background thread, tray on main thread.
/// The main thread drives the tray event loop (required on macOS; safe everywhere).
fn run_with_tray() {
use std::sync::mpsc;
enum TrayMessage {
OpenBrowser,
Quit,
}
// Channel to receive the dynamically-bound port from the server thread
let (port_tx, port_rx) = mpsc::channel::<u16>();
// Start the Axum server on a background thread with its own tokio runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(Some(port_tx)));
});
// Wait for the server to report its port
let port = port_rx.recv().expect("Server failed to start");
let url = format!("http://localhost:{}", port);
// Auto-open browser after a short delay (non-blocking)
let url_for_open = url.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(400));
let _ = webbrowser::open(&url_for_open);
});
// Set up system tray icon and menu
let (tx, rx) = mpsc::sync_channel::<TrayMessage>(2);
let mut tray = match tray_item::TrayItem::new("Ironpad", tray_icon()) {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to create system tray: {}. Running headless.", e);
// Keep the process alive so the server thread continues
loop {
std::thread::park();
}
}
};
let tx_open = tx.clone();
let _ = tray.add_menu_item("Open in Browser", move || {
let _ = tx_open.send(TrayMessage::OpenBrowser);
});
let tx_quit = tx;
let _ = tray.add_menu_item("Quit", move || {
let _ = tx_quit.send(TrayMessage::Quit);
});
// Main-thread event loop — processes tray menu actions
for msg in rx {
match msg {
TrayMessage::OpenBrowser => {
let _ = webbrowser::open(&url);
}
TrayMessage::Quit => {
info!("Quit requested from system tray");
std::process::exit(0);
}
}
}
}

View File

@@ -10,8 +10,9 @@ use std::fs;
use crate::config; use crate::config;
use crate::routes::tasks::{ use crate::routes::tasks::{
create_task_handler, delete_task_handler, get_task_handler, list_project_tasks_handler, add_comment_handler, create_task_handler, delete_comment_handler, delete_task_handler,
toggle_task_handler, update_task_content_handler, update_task_meta_handler, CreateTaskRequest, get_task_handler, list_project_tasks_handler, toggle_task_handler,
update_task_content_handler, update_task_meta_handler, AddCommentRequest, CreateTaskRequest,
UpdateTaskMetaRequest, UpdateTaskMetaRequest,
}; };
use crate::services::filesystem; use crate::services::filesystem;
@@ -91,6 +92,14 @@ pub fn router() -> Router {
) )
.route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task)) .route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task))
.route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta)) .route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta))
.route(
"/{id}/tasks/{task_id}/comments",
axum::routing::post(add_project_task_comment),
)
.route(
"/{id}/tasks/{task_id}/comments/{comment_index}",
axum::routing::delete(delete_project_task_comment),
)
// Note routes // Note routes
.route( .route(
"/{id}/notes", "/{id}/notes",
@@ -143,6 +152,19 @@ async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> imp
delete_task_handler(id, task_id).await delete_task_handler(id, task_id).await
} }
async fn add_project_task_comment(
Path((id, task_id)): Path<(String, String)>,
Json(payload): Json<AddCommentRequest>,
) -> impl IntoResponse {
add_comment_handler(id, task_id, payload).await
}
async fn delete_project_task_comment(
Path((id, task_id, comment_index)): Path<(String, String, usize)>,
) -> impl IntoResponse {
delete_comment_handler(id, task_id, comment_index).await
}
async fn list_projects() -> impl IntoResponse { async fn list_projects() -> impl IntoResponse {
match list_projects_impl() { match list_projects_impl() {
Ok(projects) => Json(projects).into_response(), Ok(projects) => Json(projects).into_response(),

View File

@@ -7,6 +7,13 @@ use crate::config;
use crate::services::filesystem; use crate::services::filesystem;
use crate::services::frontmatter; use crate::services::frontmatter;
/// A single comment entry on a task
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub date: String,
pub text: String,
}
/// Task summary for list views /// Task summary for list views
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Task { pub struct Task {
@@ -25,6 +32,7 @@ pub struct Task {
pub path: String, pub path: String,
pub created: String, pub created: String,
pub updated: String, pub updated: String,
pub last_comment: Option<String>,
} }
/// Task with full content for detail view /// Task with full content for detail view
@@ -46,6 +54,7 @@ pub struct TaskWithContent {
pub created: String, pub created: String,
pub updated: String, pub updated: String,
pub content: String, pub content: String,
pub comments: Vec<Comment>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -67,6 +76,11 @@ pub struct UpdateTaskMetaRequest {
pub recurrence_interval: Option<u32>, pub recurrence_interval: Option<u32>,
} }
#[derive(Debug, Deserialize)]
pub struct AddCommentRequest {
pub text: String,
}
pub fn router() -> Router { pub fn router() -> Router {
Router::new().route("/", get(list_all_tasks_handler)) Router::new().route("/", get(list_all_tasks_handler))
} }
@@ -178,6 +192,43 @@ pub async fn delete_task_handler(project_id: String, task_id: String) -> impl In
} }
} }
/// Add a comment to a task
pub async fn add_comment_handler(
project_id: String,
task_id: String,
payload: AddCommentRequest,
) -> impl IntoResponse {
match add_comment_impl(&project_id, &task_id, &payload.text) {
Ok(task) => (StatusCode::CREATED, 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 add comment: {}", err),
)
.into_response(),
}
}
/// Delete a comment from a task by index
pub async fn delete_comment_handler(
project_id: String,
task_id: String,
comment_index: usize,
) -> impl IntoResponse {
match delete_comment_impl(&project_id, &task_id, comment_index) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
Err(err) if err.contains("out of range") => {
(StatusCode::BAD_REQUEST, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete comment: {}", err),
)
.into_response(),
}
}
// ============ Implementation Functions ============ // ============ Implementation Functions ============
fn get_tasks_dir(project_id: &str) -> std::path::PathBuf { fn get_tasks_dir(project_id: &str) -> std::path::PathBuf {
@@ -233,6 +284,29 @@ fn list_project_tasks_impl(project_id: &str) -> Result<Vec<Task>, String> {
Ok(tasks) Ok(tasks)
} }
/// Parse comments from frontmatter YAML sequence.
fn parse_comments(fm: &serde_yaml::Mapping) -> Vec<Comment> {
fm.get(&serde_yaml::Value::from("comments"))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| {
let map = item.as_mapping()?;
let date = map
.get(&serde_yaml::Value::from("date"))
.and_then(|v| v.as_str())
.map(String::from)?;
let text = map
.get(&serde_yaml::Value::from("text"))
.and_then(|v| v.as_str())
.map(String::from)?;
Some(Comment { date, text })
})
.collect()
})
.unwrap_or_default()
}
/// Shared helper: extract common task fields from frontmatter. /// Shared helper: extract common task fields from frontmatter.
/// Eliminates duplication between parse_task_file and parse_task_with_content. /// 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 { fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &str) -> Task {
@@ -242,6 +316,9 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let comments = parse_comments(fm);
let last_comment = comments.last().map(|c| c.text.clone());
Task { Task {
id: frontmatter::get_str_or(fm, "id", &filename), id: frontmatter::get_str_or(fm, "id", &filename),
title: frontmatter::get_str_or(fm, "title", "Untitled"), title: frontmatter::get_str_or(fm, "title", "Untitled"),
@@ -258,6 +335,7 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
path: format!("projects/{}/tasks/{}.md", project_id, filename), path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: frontmatter::get_str_or(fm, "created", ""), created: frontmatter::get_str_or(fm, "created", ""),
updated: frontmatter::get_str_or(fm, "updated", ""), updated: frontmatter::get_str_or(fm, "updated", ""),
last_comment,
} }
} }
@@ -355,6 +433,7 @@ fn create_task_impl(
created: now_str.clone(), created: now_str.clone(),
updated: now_str, updated: now_str,
content: body, content: body,
comments: Vec::new(),
}) })
} }
@@ -407,6 +486,7 @@ fn parse_task_with_content(
project_id: &str, project_id: &str,
) -> Result<TaskWithContent, String> { ) -> Result<TaskWithContent, String> {
let task = extract_task_fields(fm, path, project_id); let task = extract_task_fields(fm, path, project_id);
let comments = parse_comments(fm);
Ok(TaskWithContent { Ok(TaskWithContent {
id: task.id, id: task.id,
title: task.title, title: task.title,
@@ -424,6 +504,7 @@ fn parse_task_with_content(
created: task.created, created: task.created,
updated: task.updated, updated: task.updated,
content: body.to_string(), content: body.to_string(),
comments,
}) })
} }
@@ -683,6 +764,7 @@ fn create_recurring_task_impl(
created: now_str.clone(), created: now_str.clone(),
updated: now_str, updated: now_str,
content: body, content: body,
comments: Vec::new(),
}) })
} }
@@ -776,6 +858,102 @@ fn update_task_meta_impl(
Ok(task) Ok(task)
} }
/// Serialize a Vec<Comment> into a YAML sequence Value.
fn comments_to_yaml(comments: &[Comment]) -> serde_yaml::Value {
let seq: Vec<serde_yaml::Value> = comments
.iter()
.map(|c| {
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::from("date"),
serde_yaml::Value::from(c.date.as_str()),
);
map.insert(
serde_yaml::Value::from("text"),
serde_yaml::Value::from(c.text.as_str()),
);
serde_yaml::Value::Mapping(map)
})
.collect();
serde_yaml::Value::Sequence(seq)
}
fn add_comment_impl(
project_id: &str,
task_id: &str,
text: &str,
) -> Result<TaskWithContent, String> {
let task_path = find_task_path(project_id, task_id)?;
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
// Parse existing comments and append the new one
let mut comments = parse_comments(&fm);
let now = chrono::Utc::now().to_rfc3339();
comments.push(Comment {
date: now.clone(),
text: text.to_string(),
});
// Write comments back to frontmatter
fm.insert(
serde_yaml::Value::from("comments"),
comments_to_yaml(&comments),
);
// Update timestamp
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
parse_task_with_content(&fm, &body, &task_path, project_id)
}
fn delete_comment_impl(
project_id: &str,
task_id: &str,
comment_index: usize,
) -> Result<TaskWithContent, String> {
let task_path = find_task_path(project_id, task_id)?;
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
let mut comments = parse_comments(&fm);
if comment_index >= comments.len() {
return Err("Comment index out of range".to_string());
}
comments.remove(comment_index);
// Write comments back (or remove key if empty)
if comments.is_empty() {
fm.remove(&serde_yaml::Value::from("comments"));
} else {
fm.insert(
serde_yaml::Value::from("comments"),
comments_to_yaml(&comments),
);
}
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
parse_task_with_content(&fm, &body, &task_path, project_id)
}
fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> { fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> {
let task_path = find_task_path(project_id, task_id)?; let task_path = find_task_path(project_id, task_id)?;

40
build-local.ps1 Normal file
View File

@@ -0,0 +1,40 @@
# build-local.ps1 — Build a local release package for testing
# Creates: release/ironpad.exe + release/static/
# Usage: .\build-local.ps1
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
Write-Host "`n=== Ironpad Local Build ===" -ForegroundColor Cyan
# 1. Build frontend
Write-Host "`n[1/4] Building frontend..." -ForegroundColor Yellow
Push-Location "$root\frontend"
npm run build
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Frontend build failed" }
Pop-Location
# 2. Build backend (release)
Write-Host "`n[2/4] Building backend (release)..." -ForegroundColor Yellow
Push-Location "$root\backend"
cargo build --release
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Backend build failed" }
Pop-Location
# 3. Assemble release folder
Write-Host "`n[3/4] Assembling release folder..." -ForegroundColor Yellow
$releaseDir = "$root\release"
if (Test-Path $releaseDir) { Remove-Item -Recurse -Force $releaseDir }
New-Item -ItemType Directory -Path $releaseDir | Out-Null
New-Item -ItemType Directory -Path "$releaseDir\static" | Out-Null
Copy-Item "$root\backend\target\release\ironpad.exe" "$releaseDir\ironpad.exe"
Copy-Item "$root\frontend\dist\*" "$releaseDir\static\" -Recurse
# 4. Done
$size = [math]::Round((Get-Item "$releaseDir\ironpad.exe").Length / 1MB, 1)
Write-Host "`n[4/4] Done!" -ForegroundColor Green
Write-Host " Binary: $releaseDir\ironpad.exe ($size MB)"
Write-Host " Static: $releaseDir\static\"
Write-Host "`nTo run: cd release && .\ironpad.exe" -ForegroundColor Cyan
Write-Host "(Tray icon will appear - right-click for menu)`n"

View File

@@ -267,7 +267,12 @@ GET /api/projects/:id/tasks
"priority": "high", "priority": "high",
"due_date": "2026-02-10", "due_date": "2026-02-10",
"is_active": true, "is_active": true,
"content": "## Requirements\n\n- Item 1\n- Item 2", "tags": ["backend", "api"],
"parent_id": null,
"recurrence": null,
"recurrence_interval": null,
"project_id": "ferrite",
"last_comment": "API endpoint done, moving to frontend",
"path": "projects/ferrite/tasks/task-20260205-123456.md", "path": "projects/ferrite/tasks/task-20260205-123456.md",
"created": "2026-02-05T12:34:56Z", "created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z" "updated": "2026-02-05T12:34:56Z"
@@ -338,6 +343,72 @@ PUT /api/projects/:id/tasks/:taskId/toggle
DELETE /api/projects/:id/tasks/:taskId DELETE /api/projects/:id/tasks/:taskId
``` ```
### Add Comment
```http
POST /api/projects/:id/tasks/:taskId/comments
Content-Type: application/json
{
"text": "Started work on this API integration is in progress."
}
```
**Response:** `201 Created`
```json
{
"id": "task-20260216-120000",
"title": "Implement feature X",
"completed": false,
"section": "Active",
"is_active": true,
"comments": [
{
"date": "2026-02-16T10:30:00+00:00",
"text": "Created initial spec"
},
{
"date": "2026-02-16T12:00:00+00:00",
"text": "Started work on this — API integration is in progress."
}
],
"content": "## Requirements\n\n- Item 1\n- Item 2",
"...": "other task fields"
}
```
Comments are stored as a YAML sequence in the task's frontmatter. The response returns the full `TaskWithContent` object with all comments.
### Delete Comment
```http
DELETE /api/projects/:id/tasks/:taskId/comments/:commentIndex
```
Removes the comment at the given zero-based index.
**Response:**
```json
{
"id": "task-20260216-120000",
"comments": [],
"...": "full TaskWithContent"
}
```
### Comment in List Views
When listing tasks (`GET /api/projects/:id/tasks` or `GET /api/tasks`), each task includes a `last_comment` field with the text of the most recent comment (or `null` if no comments exist). This enables showing a quick status summary without loading the full task.
```json
{
"id": "task-20260216-120000",
"title": "Implement feature X",
"last_comment": "Started work on this — API integration is in progress.",
"...": "other task fields"
}
```
--- ---
## All Tasks ## All Tasks

View File

@@ -320,6 +320,11 @@ interface Project {
### Task ### Task
```typescript ```typescript
interface Comment {
date: string // ISO 8601 timestamp
text: string // Comment body
}
interface Task { interface Task {
id: string // e.g., "task-20260205-123456" id: string // e.g., "task-20260205-123456"
title: string title: string
@@ -328,13 +333,52 @@ interface Task {
priority?: string priority?: string
due_date?: string due_date?: string
is_active: boolean is_active: boolean
content: string // Markdown description tags: string[]
parent_id?: string // Links subtask to parent
recurrence?: string // "daily" | "weekly" | "monthly" | "yearly"
recurrence_interval?: number
last_comment?: string // Most recent comment text (list views)
path: string path: string
created: string created: string
updated: string updated: string
} }
interface TaskWithContent extends Task {
content: string // Markdown description
comments: Comment[] // Full comment history
}
``` ```
#### Task File Format
```markdown
---
id: ferrite-task-20260216-120000
type: task
title: Implement feature X
completed: false
section: Active
priority: normal
is_active: true
tags:
- backend
- api
comments:
- date: "2026-02-16T10:30:00+00:00"
text: Started initial research
- date: "2026-02-16T14:00:00+00:00"
text: API endpoint done, moving to frontend
created: "2026-02-16T12:00:00+00:00"
updated: "2026-02-16T14:00:00+00:00"
---
# Implement feature X
Detailed description in markdown...
```
Comments are stored as a YAML sequence directly in frontmatter, keeping everything in a single file. The `last_comment` field in list views is derived at read time from the last entry in the sequence.
## API Design ## API Design
### REST Conventions ### REST Conventions

View File

@@ -1,4 +1,6 @@
# System Tray Implementation (v0.2.0) # System Tray Implementation (v0.2.0)
**Status:** Implemented
**Goal:** Replace the CMD window with a system tray icon. Users interact via tray menu: "Open in Browser" or "Quit". No console window on Windows. **Goal:** Replace the CMD window with a system tray icon. Users interact via tray menu: "Open in Browser" or "Quit". No console window on Windows.
@@ -6,100 +8,101 @@
## Overview ## Overview
- **Scope:** Single codebase, cross-platform. CI/CD unchangedsame build pipeline produces one binary per OS. - **Scope:** Single codebase, cross-platform. CI/CD unchangedsame build pipeline produces one binary per OS.
- **Complexity:** Lowmedium. Uses a cross-platform Rust crate; platform-specific code is minimal. - **Crate:** `tray-item = "0.10"` (cross-platform tray icon with simple API)
- **Windows icon:** `windows-sys = "0.52"` for loading the standard application icon via `LoadIconW`
--- ---
## Implementation Steps ## What Was Implemented
### 1. Add Tray Crate Dependency ### 1. Dependencies (`backend/Cargo.toml`)
Add to `backend/Cargo.toml`:
```toml ```toml
# System tray (production mode) # System tray (production mode)
tray-item = "0.10" tray-item = "0.10"
# Windows system icon loading (for tray icon)
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging"] }
``` ```
Alternative: `tray-icon` (more features, heavier; requires event loop integration). ### 2. Windows: Hide Console Window (`backend/src/main.rs`)
### 2. Windows: Hide Console Window
Add near the top of `backend/src/main.rs` (after `mod` declarations if any, before `fn main`):
```rust ```rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
``` ```
- **Debug builds:** Console remains (for logs). - **Debug builds (`cargo run`):** Console remains for logs.
- **Release builds:** No CMD window on Windows. - **Release builds (`cargo build --release`):** No CMD window on Windows.
### 3. Remove Auto-Open Browser on Startup ### 3. Restructured `main()` for Dual-Mode Operation
In `main.rs`, remove or conditionally disable the auto-open logic (the `tokio::spawn` block that calls `webbrowser::open()`). The user will open the browser from the tray menu instead. ```
main()
### 4. Add Tray Icon and Menu (Production Mode Only) ├── Development mode (no static/index.html next to exe)
│ └── Normal tokio runtime on main thread, API-only, no tray
When `has_frontend` is true (production mode): └── Production mode (static/index.html exists)
└── run_with_tray()
1. Create tray icon with an appropriate icon (or placeholder). ├── Background thread: tokio runtime + Axum server
2. Add menu items: ├── mpsc channel: server sends bound port back to main thread
- **"Open in Browser"** — calls `webbrowser::open()` with `http://localhost:{port}`. ├── Auto-open browser (400ms delay)
- **"Quit"** — shuts down the server and exits the process. └── Main thread: tray icon + event loop
### 5. Threading Considerations
- **macOS:** Some tray crates expect event handling on the main thread. May need to run tray logic on main thread and spawn the Axum server on a background thread, or use crate-specific patterns.
- **Windows/Linux:** Usually more flexible; verify with the chosen crates docs.
### 6. CI/CD Changes (If Needed)
Current `release.yml` builds for Windows, macOS, and Linux. Likely no changes required.
If using a crate that needs GTK on Linux (e.g. `tray-icon`), add to the "Install system dependencies (Linux)" step:
```yaml
sudo apt-get install -y cmake libgtk-3-dev libappindicator3-dev
``` ```
Note: Linux users would then need GTK installed at runtime. For `tray-item`, check whether it has different Linux deps. ### 4. Tray Icon (Platform-Specific)
| Platform | Icon Source |
|----------|------------|
| Windows | `IDI_APPLICATION` via `LoadIconW` (standard system app icon) |
| macOS | `IconSource::Resource("")` (default icon) |
| Linux | `IconSource::Resource("application-x-executable")` (icon theme) |
### 5. Tray Menu
- **"Open in Browser"** — calls `webbrowser::open()` with `http://localhost:{port}`
- **"Quit"** — calls `std::process::exit(0)` to shut down server and exit
### 6. Threading Model
The tray event loop runs on the **main thread** (required on macOS, safe on all platforms). The Axum server runs on a **background thread** with its own `tokio::runtime::Runtime`. Port discovery uses `std::sync::mpsc::channel`.
--- ---
## Behaviour Summary ## Behaviour Summary
| Before (v0.1.0) | After (v0.2.0) | | Before (v0.1.0) | After (v0.2.0) |
|------------------------|--------------------------------| |------------------------|------------------------------------|
| CMD window visible | No console window (Windows) | | CMD window visible | No console window (Windows release)|
| Browser opens on start | Browser opens via tray menu | | Browser opens on start | Browser opens on start + via tray |
| Quit via Ctrl+C | Quit via tray menu | | Quit via Ctrl+C | Quit via tray menu or Ctrl+C (dev) |
--- ---
## Testing Checklist ## Testing Checklist
- [ ] Windows: No CMD window when running release binary. - [x] Backend compiles with new dependencies (`cargo check`)
- [ ] Windows: Tray icon appears; "Open in Browser" opens correct URL. - [ ] Windows: No CMD window when running release binary
- [ ] Windows: "Quit" exits cleanly. - [ ] Windows: Tray icon appears; "Open in Browser" opens correct URL
- [ ] macOS: Tray icon in menu bar; menu works. - [ ] Windows: "Quit" exits cleanly
- [ ] Linux: Tray icon in system tray; menu works. - [ ] macOS: Tray icon in menu bar; menu works
- [ ] Development mode (`cargo run`): Behaviour unchanged (no tray, API-only). - [ ] Linux: Tray icon in system tray; menu works
- [ ] Development mode (`cargo run`): Behaviour unchanged (no tray, API-only)
--- ---
## Icon Asset ## Icon Asset
Youll need a tray icon (e.g. 16×16 or 32×32 PNG). Options: Currently using platform default icons (Windows system app icon, macOS default, Linux icon theme). To use a custom branded icon:
- Extract from existing branding/logo. 1. Create a 16×16 or 32×32 PNG/ICO
- Use a simple placeholder (e.g. filled circle) for initial implementation. 2. On Windows: embed via `.rc` resource file and `build.rs`, use `IconSource::Resource("icon-name")`
- Store in `backend/` or `backend/static/` and load at runtime. 3. On macOS/Linux: use `IconSource::Data { width, height, data }` with raw RGBA bytes
--- ---
## References ## References
- [tray-item crate](https://crates.io/crates/tray-item) - [tray-item crate](https://crates.io/crates/tray-item)
- [tray-icon crate](https://crates.io/crates/tray-icon) (alternative) - [tray-icon crate](https://crates.io/crates/tray-icon) (alternative, heavier)
- `#![windows_subsystem = "windows"]` — [Rust embed documentation](https://doc.rust-lang.org/reference/conditional-compilation.html#windows_subsystem) - `#![windows_subsystem = "windows"]` — [Rust conditional compilation](https://doc.rust-lang.org/reference/conditional-compilation.html#windows_subsystem)

View File

@@ -6,6 +6,8 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="apple-touch-icon" href="/logo-180.png" />
<title>Ironpad</title> <title>Ironpad</title>
<script> <script>
// Apply saved theme immediately to prevent flash // Apply saved theme immediately to prevent flash

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
frontend/public/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -15,7 +15,8 @@ import type {
CommitDetail, CommitDetail,
DiffInfo, DiffInfo,
RemoteInfo, RemoteInfo,
DailyNote DailyNote,
Comment
} from '../types' } from '../types'
const API_BASE = '/api' const API_BASE = '/api'
@@ -150,6 +151,20 @@ export const tasksApi = {
delete: (projectId: string, taskId: string) => delete: (projectId: string, taskId: string) =>
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, { request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
method: 'DELETE' method: 'DELETE'
}),
// Add a comment to a task
addComment: (projectId: string, taskId: string, text: string) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
}),
// Delete a comment from a task by index
deleteComment: (projectId: string, taskId: string, commentIndex: number) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments/${commentIndex}`, {
method: 'DELETE'
}) })
} }

View File

@@ -65,7 +65,10 @@ function goHome() {
<template> <template>
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1> <h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">
<img src="/logo-32.png" alt="" class="app-logo" />
Ironpad
</h1>
<div class="project-selector"> <div class="project-selector">
<button class="project-button" @click="toggleDropdown"> <button class="project-button" @click="toggleDropdown">
@@ -175,6 +178,15 @@ function goHome() {
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
}
.app-logo {
width: 22px;
height: 22px;
object-fit: contain;
} }
.project-selector { .project-selector {

View File

@@ -181,6 +181,42 @@ export const useTasksStore = defineStore('tasks', () => {
} }
} }
async function addComment(projectId: string, taskId: string, text: string) {
try {
error.value = null
const task = await tasksApi.addComment(projectId, taskId, text)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to add comment: ${err}`
throw err
}
}
async function deleteComment(projectId: string, taskId: string, commentIndex: number) {
try {
error.value = null
const task = await tasksApi.deleteComment(projectId, taskId, commentIndex)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to delete comment: ${err}`
throw err
}
}
function selectTask(task: Task | null) { function selectTask(task: Task | null) {
if (task && currentProjectId.value) { if (task && currentProjectId.value) {
loadTask(currentProjectId.value, task.id) loadTask(currentProjectId.value, task.id)
@@ -227,6 +263,8 @@ export const useTasksStore = defineStore('tasks', () => {
toggleTask, toggleTask,
updateTaskMeta, updateTaskMeta,
deleteTask, deleteTask,
addComment,
deleteComment,
selectTask, selectTask,
clearSelectedTask, clearSelectedTask,
clearProjectTasks, clearProjectTasks,

View File

@@ -40,6 +40,11 @@ export interface ProjectNoteWithContent extends ProjectNote {
content: string content: string
} }
export interface Comment {
date: string
text: string
}
export interface Task { export interface Task {
id: string id: string
title: string title: string
@@ -56,10 +61,12 @@ export interface Task {
path: string path: string
created: string created: string
updated: string updated: string
last_comment?: string
} }
export interface TaskWithContent extends Task { export interface TaskWithContent extends Task {
content: string content: string
comments: Comment[]
} }
export interface SearchResult { export interface SearchResult {

View File

@@ -93,21 +93,144 @@ function formatDate(y: number, m: number, d: number): string {
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
} }
// Tasks grouped by due date // Calendar entry: a task plus whether it's a recurring occurrence (vs a real due_date)
interface CalendarEntry {
task: Task
isRecurring: boolean
}
// Tasks grouped by due date (regular, non-recurring placements)
const tasksByDate = computed(() => { const tasksByDate = computed(() => {
const map = new Map<string, Task[]>() const map = new Map<string, CalendarEntry[]>()
for (const task of tasksStore.allTasks) { for (const task of tasksStore.allTasks) {
if (task.due_date && !task.completed) { if (task.due_date && !task.completed) {
const existing = map.get(task.due_date) || [] const existing = map.get(task.due_date) || []
existing.push(task) existing.push({ task, isRecurring: false })
map.set(task.due_date, existing) map.set(task.due_date, existing)
} }
} }
return map return map
}) })
function getTasksForDate(dateStr: string): Task[] { // Expand recurring tasks into the visible calendar range
return tasksByDate.value.get(dateStr) || [] const recurringByDate = computed(() => {
const map = new Map<string, CalendarEntry[]>()
const days = calendarDays.value
if (days.length === 0) return map
const rangeStart = days[0].date
const rangeEnd = days[days.length - 1].date
for (const task of tasksStore.allTasks) {
if (task.completed || !task.recurrence) continue
const interval = task.recurrence_interval || 1
const anchorStr = task.due_date || task.created?.split('T')[0]
if (!anchorStr) continue
const anchor = new Date(anchorStr + 'T00:00:00')
const start = new Date(rangeStart + 'T00:00:00')
const end = new Date(rangeEnd + 'T00:00:00')
const occurrences: string[] = []
switch (task.recurrence) {
case 'daily': {
let cur = new Date(anchor)
// Advance to range start (skip past occurrences efficiently)
if (cur < start) {
const daysBehind = Math.floor((start.getTime() - cur.getTime()) / 86400000)
const skipCycles = Math.floor(daysBehind / interval) * interval
cur.setDate(cur.getDate() + skipCycles)
}
while (cur <= end && occurrences.length < 60) {
if (cur >= start) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + interval)
}
break
}
case 'weekly': {
const targetDow = anchor.getDay()
let cur = new Date(start)
// Find first matching weekday in range
while (cur.getDay() !== targetDow && cur <= end) {
cur.setDate(cur.getDate() + 1)
}
while (cur <= end && occurrences.length < 10) {
// Check interval alignment (weeks since anchor)
const msDiff = cur.getTime() - anchor.getTime()
const weeksDiff = Math.round(msDiff / (7 * 86400000))
if (weeksDiff >= 0 && weeksDiff % interval === 0) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + 7)
}
break
}
case 'monthly': {
const targetDay = anchor.getDate()
// Check months visible in the calendar range (usually 3: prev, current, next)
for (let mOffset = -1; mOffset <= 1; mOffset++) {
let y = currentYear.value
let m = currentMonth.value + mOffset
if (m < 0) { m = 11; y-- }
if (m > 11) { m = 0; y++ }
const daysInM = new Date(y, m + 1, 0).getDate()
const day = Math.min(targetDay, daysInM)
const dateStr = formatDate(y, m, day)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const monthsDiff = (y - anchor.getFullYear()) * 12 + (m - anchor.getMonth())
if (monthsDiff >= 0 && monthsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
}
break
}
case 'yearly': {
const tgtMonth = anchor.getMonth()
const tgtDay = anchor.getDate()
const y = currentYear.value
const dateStr = formatDate(y, tgtMonth, tgtDay)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const yearsDiff = y - anchor.getFullYear()
if (yearsDiff >= 0 && yearsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
break
}
}
for (const dateStr of occurrences) {
// Skip if the task already appears on this date via its due_date
if (task.due_date === dateStr) continue
const existing = map.get(dateStr) || []
existing.push({ task, isRecurring: true })
map.set(dateStr, existing)
}
}
return map
})
function dateFromObj(d: Date): string {
return formatDate(d.getFullYear(), d.getMonth(), d.getDate())
}
// Merge regular due-date tasks and recurring occurrences for a given date
function getEntriesForDate(dateStr: string): CalendarEntry[] {
const regular = tasksByDate.value.get(dateStr) || []
const recurring = recurringByDate.value.get(dateStr) || []
return [...regular, ...recurring]
}
// Convenience: check if a date has any tasks (for cell styling)
function hasTasksOnDate(dateStr: string): boolean {
return getEntriesForDate(dateStr).length > 0
} }
function hasDailyNote(dateStr: string): boolean { function hasDailyNote(dateStr: string): boolean {
@@ -211,7 +334,7 @@ onMounted(async () => {
:class="['calendar-cell', { :class="['calendar-cell', {
'other-month': !day.isCurrentMonth, 'other-month': !day.isCurrentMonth,
'is-today': day.isToday, 'is-today': day.isToday,
'has-tasks': getTasksForDate(day.date).length > 0, 'has-tasks': hasTasksOnDate(day.date),
}]" }]"
> >
<div class="cell-header" @click="clickDate(day.date)"> <div class="cell-header" @click="clickDate(day.date)">
@@ -220,20 +343,21 @@ onMounted(async () => {
</div> </div>
<div class="cell-tasks"> <div class="cell-tasks">
<div <div
v-for="task in getTasksForDate(day.date).slice(0, 3)" v-for="entry in getEntriesForDate(day.date).slice(0, 3)"
:key="task.id" :key="`${entry.task.id}-${entry.isRecurring ? 'r' : 'd'}`"
:class="['cell-task', formatDueClass(day.date)]" :class="['cell-task', formatDueClass(day.date), { recurring: entry.isRecurring }]"
@click.stop="clickTask(task)" @click.stop="clickTask(entry.task)"
:title="`${projectName(task.project_id)}: ${task.title}`" :title="`${projectName(entry.task.project_id)}: ${entry.task.title}${entry.isRecurring ? ' (recurring)' : ''}`"
> >
{{ task.title }} <span v-if="entry.isRecurring" class="recurring-icon" title="Recurring">&#x21bb;</span>
{{ entry.task.title }}
</div> </div>
<div <div
v-if="getTasksForDate(day.date).length > 3" v-if="getEntriesForDate(day.date).length > 3"
class="cell-more" class="cell-more"
@click="clickDate(day.date)" @click="clickDate(day.date)"
> >
+{{ getTasksForDate(day.date).length - 3 }} more +{{ getEntriesForDate(day.date).length - 3 }} more
</div> </div>
</div> </div>
</div> </div>
@@ -413,6 +537,17 @@ onMounted(async () => {
border-left-color: var(--color-warning); border-left-color: var(--color-warning);
} }
.cell-task.recurring {
border-left-style: dashed;
opacity: 0.85;
}
.recurring-icon {
font-size: 10px;
margin-right: 2px;
opacity: 0.7;
}
.cell-more { .cell-more {
font-size: 10px; font-size: 10px;
color: var(--color-text-secondary); color: var(--color-text-secondary);

View File

@@ -140,17 +140,24 @@ onMounted(async () => {
@click="goToTask(project.id, task)" @click="goToTask(project.id, task)"
> >
<span class="task-checkbox">&#9744;</span> <span class="task-checkbox">&#9744;</span>
<span class="task-title">{{ task.title }}</span> <div class="card-task-info">
<div class="task-meta"> <div class="card-task-row">
<span <span class="task-title">{{ task.title }}</span>
v-for="tag in task.tags?.slice(0, 2)" <div class="task-meta">
:key="tag" <span
class="task-tag" v-for="tag in task.tags?.slice(0, 2)"
>{{ tag }}</span> :key="tag"
<span class="task-tag"
v-if="task.due_date && formatDueDate(task.due_date)" >{{ tag }}</span>
:class="['task-due', formatDueDate(task.due_date)?.class]" <span
>{{ formatDueDate(task.due_date)?.text }}</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="task.last_comment" class="card-task-comment">
{{ task.last_comment }}
</div>
</div> </div>
</div> </div>
<div <div
@@ -304,6 +311,20 @@ onMounted(async () => {
font-size: 14px; font-size: 14px;
} }
.card-task-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-task-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-task-item .task-title { .card-task-item .task-title {
flex: 1; flex: 1;
font-size: 13px; font-size: 13px;
@@ -313,6 +334,16 @@ onMounted(async () => {
min-width: 0; min-width: 0;
} }
.card-task-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
opacity: 0.75;
}
.task-meta { .task-meta {
display: flex; display: flex;
gap: 4px; gap: 4px;

View File

@@ -338,6 +338,59 @@ async function addSubtask() {
} }
} }
// ============ Comment Management ============
const newCommentText = ref('')
const showCommentForm = ref(false)
function openCommentForm() {
showCommentForm.value = true
newCommentText.value = ''
}
function closeCommentForm() {
showCommentForm.value = false
newCommentText.value = ''
}
async function addComment() {
if (!newCommentText.value.trim() || !tasksStore.selectedTask) return
try {
await tasksStore.addComment(projectId.value, tasksStore.selectedTask.id, newCommentText.value.trim())
closeCommentForm()
} catch {
// Error handled in store
}
}
async function deleteComment(index: number) {
if (!tasksStore.selectedTask) return
try {
await tasksStore.deleteComment(projectId.value, tasksStore.selectedTask.id, index)
} catch {
// Error handled in store
}
}
function formatCommentDate(dateStr: string): string {
try {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
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()
} catch {
return dateStr
}
}
// ============ Due Date Management ============ // ============ Due Date Management ============
async function setDueDate(date: string) { async function setDueDate(date: string) {
@@ -650,6 +703,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }} {{ formatDueDate(task.due_date)?.text }}
</span> </span>
</div> </div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div> </div>
</template> </template>
<button <button
@@ -718,6 +774,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }} {{ formatDueDate(task.due_date)?.text }}
</span> </span>
</div> </div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div> </div>
</template> </template>
<button <button
@@ -921,6 +980,49 @@ onUnmounted(() => {
<button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button> <button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button>
</div> </div>
<!-- Comments Section -->
<div class="comments-panel">
<div class="comments-header">
<span class="comments-label">Comments ({{ tasksStore.selectedTask.comments?.length || 0 }})</span>
<button v-if="!showCommentForm" class="tag-add-btn" @click="openCommentForm">+ Comment</button>
</div>
<!-- Add comment form -->
<div v-if="showCommentForm" class="comment-form">
<textarea
v-model="newCommentText"
class="comment-input"
placeholder="Add a comment or status update..."
rows="2"
@keydown.ctrl.enter="addComment"
@keyup.escape="closeCommentForm"
autofocus
></textarea>
<div class="comment-form-actions">
<button class="primary small" @click="addComment" :disabled="!newCommentText.trim()">Add Comment</button>
<button class="small" @click="closeCommentForm">Cancel</button>
<span class="comment-hint">Ctrl+Enter to submit</span>
</div>
</div>
<!-- Comment list (newest first) -->
<div v-if="tasksStore.selectedTask.comments?.length > 0" class="comment-list">
<div
v-for="(comment, index) in [...tasksStore.selectedTask.comments].reverse()"
:key="index"
class="comment-item"
>
<div class="comment-meta">
<span class="comment-date">{{ formatCommentDate(comment.date) }}</span>
<button
class="comment-delete"
@click="deleteComment(tasksStore.selectedTask.comments.length - 1 - index)"
title="Delete comment"
>&times;</button>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
</div>
<div class="editor-content"> <div class="editor-content">
<div class="editor-pane"> <div class="editor-pane">
<MilkdownEditor <MilkdownEditor
@@ -1507,6 +1609,22 @@ button.small {
color: white; color: white;
} }
/* Last comment preview in task list */
.task-last-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
opacity: 0.75;
font-style: italic;
}
.task-item.selected .task-last-comment {
color: rgba(255, 255, 255, 0.7);
}
/* Tag Editor Bar (detail panel) */ /* Tag Editor Bar (detail panel) */
.tag-editor-bar { .tag-editor-bar {
display: flex; display: flex;
@@ -1601,6 +1719,118 @@ button.small {
background: var(--color-bg-hover); background: var(--color-bg-hover);
} }
/* Comments Panel */
.comments-panel {
border-bottom: 1px solid var(--color-border);
padding: 8px 16px;
flex-shrink: 0;
max-height: 280px;
overflow-y: auto;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.comments-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.comment-form {
margin-bottom: 8px;
}
.comment-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 48px;
box-sizing: border-box;
}
.comment-input:focus {
border-color: var(--color-primary);
}
.comment-form-actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
}
.comment-hint {
font-size: 11px;
color: var(--color-text-secondary);
margin-left: auto;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.comment-item {
padding: 8px 10px;
border-radius: 6px;
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.comment-date {
font-size: 11px;
color: var(--color-text-secondary);
}
.comment-delete {
padding: 0 4px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
}
.comment-item:hover .comment-delete {
opacity: 1;
}
.comment-delete:hover {
color: var(--color-danger);
}
.comment-text {
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* Error Banner */ /* Error Banner */
.error-banner { .error-banner {
position: absolute; position: absolute;