Compare commits

52 Commits

Author SHA1 Message Date
Your Name
9cc3d55cbf bump version to 1.0.0 2026-02-16 15:51:57 +02:00
Your Name
d244bd1f19 replace fancy unicode punctuation with ASCII equivalents
Em dashes, en dashes, arrows, and ellipsis replaced with
--, -, ->, and ... respectively throughout README.
2026-02-16 15:49:53 +02:00
Your Name
fd0fe2cdcc fix README: Windows-only portable exe, remove default icon
- Platform is Windows only, single portable exe
- Remove icon image reference (using default Tauri icon)
- Use emoji header instead
- Update clone URLs to point to Gitea instance
- Remove installer/macOS/Linux references
2026-02-16 15:45:00 +02:00
Your Name
59aaa688bb add README, CC0 license, and button icon alignment fix
- Comprehensive README with full feature docs, settings reference,
  keyboard shortcuts, data storage layout, and build instructions
- CC0 1.0 Universal public domain dedication
- Fix button icon/text vertical alignment (leading-none + svg translate)
- Lock Cargo dependencies
2026-02-16 15:40:28 +02:00
Your Name
414c1f7d68 feat: typography overhaul, custom scrollbars, import/export, settings UI
Includes changes from prior sessions: Epilogue + Space Mono fonts,
OverlayScrollbars integration, markdown editor fixes, settings dialog,
import/export buttons, and various UI refinements.
2026-02-16 14:56:36 +02:00
Your Name
8dedbf6032 docs: add 15-improvements design doc and implementation plan
Also tracks Cargo.lock and BoardCardOverlay component from prior sessions.
2026-02-16 14:56:22 +02:00
Your Name
2e2740427e feat: Phase 4 - board templates, auto-backup, version history
- BoardTemplate type and storage CRUD (save/list/delete templates)
- createBoardFromTemplate factory function
- "Save as Template" in board card context menu
- User templates shown in NewBoardDialog with delete option
- Auto-backup on save with 5-minute throttle, 10 backup retention
- VersionHistoryDialog with backup list and restore confirmation
- Version History accessible from board settings dropdown
2026-02-16 14:55:58 +02:00
Your Name
6340beb5d0 feat: Phase 3 - filter bar, keyboard navigation, notifications, comments
- FilterBar component with text search, label chips, due date and priority dropdowns
- "/" keyboard shortcut and toolbar button to toggle filter bar
- Keyboard card navigation with J/K/H/L keys, Enter to open, Escape to clear
- Focus ring on keyboard-selected cards with auto-scroll
- Desktop notifications for due/overdue cards via tauri-plugin-notification
- CommentsSection component with add/delete and relative timestamps
- Filtered card count display in column headers
2026-02-16 14:52:08 +02:00
Your Name
e535177914 feat: Phase 2 card interactions - priority picker, context menu, WIP limits, column collapse, checklist reorder
- PriorityPicker component with 5 colored chips in card detail modal
- Card context menu: Move to column, Set priority, Duplicate, Delete
- duplicateCard store action (clones card, inserts after original)
- Column WIP limits with amber/red indicators when at/over limit
- Column collapse/expand to 40px vertical strip
- Checklist item drag reordering with grip handle
- Comment store actions (addComment, deleteComment) for Phase 3
2026-02-16 14:46:20 +02:00
Your Name
8ca3b81e92 feat: Phase 1 quick wins - defaultColumnWidth, due date colors, card aging, open attachments 2026-02-16 14:33:48 +02:00
Your Name
02ef3acbfe feat: Phase 0 data model - add Comment, Priority, collapsed, wipLimit fields 2026-02-16 14:31:56 +02:00
Your Name
c6fea186ef feat: custom scrollbars, portable storage, window state persistence
- Custom scrollbar CSS using ::-webkit-scrollbar for Tauri's Chromium WebView
- Portable storage: all data written next to exe in data/ folder instead of AppData
- Rust get_portable_data_dir command with runtime FS scope for exe directory
- Window size/position/maximized saved to settings on close, restored on startup
2026-02-15 22:18:50 +02:00
Your Name
bc12b5569a feat: custom calendar date picker replacing native input
Build fully custom CalendarPopover with date-fns + Radix Popover.
Month/year dropdown selectors, today button, clear button, past
dates dimmed. Rewrite DueDatePicker to use it instead of <input type=date>.
2026-02-15 22:04:31 +02:00
Your Name
03e0b132da docs: add custom date picker design document 2026-02-15 21:58:10 +02:00
Your Name
9365d16452 fix: replace onCloseRequested with beforeunload to fix window close
The Tauri onCloseRequested async handler was preventing the window
from actually closing. Using beforeunload for the save flush instead.
2026-02-15 21:46:44 +02:00
Your Name
21302bdfe9 feat: redesign card detail modal as 2-column dashboard grid
Replace 60/40 split layout with a 2x3 CSS grid where all sections
get equal weight. Cover color header, progress bar on checklist,
compact markdown editor, scroll containment on long lists.
2026-02-15 21:41:16 +02:00
Your Name
63b7de0e6f fix: window controls, dragging, and text selection
- Add Tauri permissions for window minimize/maximize/close/drag
- Rewrite WindowControls with plain buttons + stopPropagation
  to prevent drag region from capturing button clicks
- Add data-tauri-drag-region to TopBar child containers
- Make OpenPylon text pointer-events-none and select-none
2026-02-15 21:19:24 +02:00
Your Name
85c54a3768 feat: add micro-animations to TopBar, toasts, settings, and shortcut help 2026-02-15 21:01:10 +02:00
Your Name
24219bb212 feat: gesture-reactive drag overlay with tilt based on pointer velocity 2026-02-15 21:01:06 +02:00
Your Name
feccc4e17a feat: shared layout animation - card expands into detail modal 2026-02-15 21:00:29 +02:00
Your Name
1e14edda6c feat: add column stagger + card stagger + card hover/tap animations 2026-02-15 20:58:53 +02:00
Your Name
3a3c3bd2e1 feat: add stagger animations to board list and board cards 2026-02-15 20:58:03 +02:00
Your Name
1ecf04efcf feat: add AnimatePresence page transitions between views 2026-02-15 20:58:01 +02:00
Your Name
3d8bbc9ebb feat: custom window titlebar - remove native decorations, add WindowControls to TopBar 2026-02-15 20:56:55 +02:00
Your Name
6f5415c9f5 feat: lighten dark mode for HDR monitors - bump pylon + shadcn values 2026-02-15 20:56:35 +02:00
Your Name
275b663934 feat: add shared motion config with spring presets and variants 2026-02-15 20:56:29 +02:00
Your Name
3703857ccf docs: add motion, dark mode & titlebar implementation plan
13-task plan covering shared motion config, dark mode CSS tuning,
custom window titlebar, page transitions, stagger animations,
shared layout card-to-modal animation, gesture-reactive drag,
and micro-interactions across all components.
2026-02-15 20:55:31 +02:00
Your Name
1e487e95a1 docs: add motion, dark mode & custom titlebar design
Design document covering three visual improvements:
- Playful bouncy Framer Motion animations for every interaction
- Subtle dark mode lift for HDR monitors (18-22% → 25-30% lightness)
- Custom window titlebar merged into TopBar with accent-colored controls
2026-02-15 20:51:53 +02:00
Your Name
46c0df3ab8 feat: add themed scrollbar styling for light and dark modes 2026-02-15 20:34:04 +02:00
Your Name
e4edc201b9 feat: upgrade empty states with welcome message and column placeholders 2026-02-15 20:33:16 +02:00
Your Name
fffa565423 feat: add board background patterns (dots, grid, gradient) with settings dropdown 2026-02-15 20:32:43 +02:00
Your Name
e5e9483a8e feat: add keyboard shortcut help modal triggered by ? key 2026-02-15 20:32:32 +02:00
Your Name
fde10d1a3c feat: add undo/redo buttons to TopBar with tooltips 2026-02-15 20:31:35 +02:00
Your Name
6341897487 feat: add toast notification system with success, error, and info variants 2026-02-15 20:30:17 +02:00
Your Name
61a5f11f25 feat: add card cover color with picker in card detail and bar in thumbnail 2026-02-15 20:29:29 +02:00
Your Name
0ef77bc470 feat: add column color picker submenu with 10 preset colors 2026-02-15 20:29:18 +02:00
Your Name
1556529307 feat: add column color, card coverColor, and board background to data model 2026-02-15 20:28:16 +02:00
Your Name
f303d61677 feat: apply board color to TopBar border and column header accents 2026-02-15 20:26:51 +02:00
Your Name
c2928afb11 feat: rewrite settings dialog with tabbed panel - appearance, boards, shortcuts, about 2026-02-15 20:25:58 +02:00
Your Name
a7c9c83bb0 feat: apply density factor to card, column, and board spacing 2026-02-15 20:25:34 +02:00
Your Name
aa91ef35c8 feat: add density CSS variable with default value 2026-02-15 20:24:19 +02:00
Your Name
c08efb5171 feat: wire app store with appearance actions and CSS variable application 2026-02-15 20:24:09 +02:00
Your Name
03a089efda feat: expand AppSettings with appearance and board default fields 2026-02-15 20:23:47 +02:00
Your Name
a9d1ae3a28 fix: use Tauri v2 named fs permissions for appdata access 2026-02-15 20:22:54 +02:00
Your Name
1d99473a22 docs: add visual glow-up implementation plan with 17 tasks
Detailed step-by-step plan covering settings infrastructure,
UI zoom, accent colors, density, board/column/card colors,
toasts, keyboard help, backgrounds, onboarding, and polish.
2026-02-15 20:18:38 +02:00
Your Name
fceaf60462 docs: add visual glow-up design document
Comprehensive design for 12 visual polish improvements including
settings infrastructure, UI zoom, accent colors, density toggle,
board/column/card colors, toasts, and onboarding.
2026-02-15 20:14:10 +02:00
Your Name
557cf461db fix: broaden filesystem scope to include $APPDATA root directory
The glob pattern $APPDATA/openpylon/** didn't match the openpylon
directory itself, causing exists() checks to fail with a forbidden
path error on app startup.
2026-02-15 19:44:56 +02:00
Your Name
fa52a28749 fix: correct lib crate name in main.rs (temptauri_lib -> openpylon_lib) 2026-02-15 19:38:50 +02:00
Your Name
c590146be0 fix: address code review findings - data loss, race condition, broken features
- TopBar: call closeBoard() before navigating back to prevent data loss
- board-store: guard debouncedSave against race condition when board is
  closed during an in-flight save
- board-store: add missing updatedAt to setColumnWidth
- useKeyboardShortcuts: remove duplicate Ctrl+K handler that prevented
  command palette from toggling closed
- AttachmentSection: wire up Tauri file dialog for adding attachments
  with link/copy mode support
2026-02-15 19:33:25 +02:00
Your Name
2a81849c8d fix: move hooks before early return in BoardView, remove unused attachmentMode prop
Fixed React hooks rules violation where useState and useCallback were
called after a conditional return in BoardView. Removed unused
attachmentMode prop from AttachmentSection (can be re-added when file
dialog is wired up).
2026-02-15 19:30:58 +02:00
Your Name
07a4275e8c feat: add window close handler, configure minimum window size
Flush pending board saves on window close via Tauri's onCloseRequested.
Set minimum window dimensions to 800x600.
2026-02-15 19:24:00 +02:00
Your Name
4638ce046c feat: accessibility pass - semantic HTML, ARIA, focus indicators, high contrast 2026-02-15 19:19:34 +02:00
64 changed files with 17382 additions and 621 deletions

116
LICENSE Normal file
View File

@@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not invalidate
the remainder of the License, and in such case Affirmer hereby affirms that he
or she will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of action
with respect to the Work, in either case contrary to Affirmer's express
Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions or
other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this CC0
or use of the Work.
For more information, please see
<https://creativecommons.org/publicdomain/zero/1.0/>

355
README.md Normal file
View File

@@ -0,0 +1,355 @@
<h1 align="center">🏗️ OpenPylon</h1>
<p align="center">
<strong>A local-first Kanban board for people who want to own their work.</strong>
<br />
No accounts. No cloud. No telemetry. No landlords between you and your data.
</p>
<p align="center">
<img src="https://img.shields.io/badge/license-CC0_1.0-blue" alt="License: CC0 1.0" />
<img src="https://img.shields.io/badge/version-1.0.0-green" alt="Version 0.1.0" />
<img src="https://img.shields.io/badge/platform-Windows-0078D4?logo=windows" alt="Windows" />
<img src="https://img.shields.io/badge/portable-no%20install%20needed-brightgreen" alt="Portable" />
<img src="https://img.shields.io/badge/built%20with-Tauri%20v2-orange" alt="Built with Tauri v2" />
</p>
---
## 🏴 Why OpenPylon?
Your productivity tools shouldn't phone home. They shouldn't harvest your habits. They shouldn't stop working when a company pivots to AI or gets acqui-hired.
OpenPylon is a desktop Kanban application that keeps everything on your machine. Every board, card, and attachment is a plain JSON file in a folder next to the executable. Copy it to a USB drive. Back it up to a NAS. Share it with your team over a local network. The data is yours -- always has been, always will be.
No subscription. No signup. No server between you and your work. No one profits from your productivity except you.
**Built for people, not for platforms.**
---
## ✨ Features
### 📋 Boards
- **Unlimited boards** with custom accent colors and editable titles
- **Three built-in templates** -- Blank, Kanban (To Do / In Progress / Done), and Sprint (Backlog / To Do / In Progress / Review / Done)
- **Save any board as a reusable template** -- build your own workflows and share them freely
- **Duplicate entire boards** with all cards, labels, and settings preserved
- **Drag-and-drop reordering** in the board list
- **Sort boards** by name, last modified, date created, or manual drag order
- **Import and export** -- JSON (full fidelity) and CSV (spreadsheet-compatible); imports from Trello JSON too
### 🏛️ Columns
- **Add, rename, reorder, and delete** columns with drag-and-drop
- **Three column widths** -- Narrow, Standard, Wide
- **Column colors** -- 10 preset hues or no color
- **WIP limits** -- optional per-column capacity limits (3, 5, 7, or 10) with amber/red header warnings when the collective workload exceeds what's sustainable
- **Collapse columns** to a narrow vertical strip showing just the title and card count -- keep things tidy without losing context
### 🃏 Cards
- **Drag-and-drop** cards within and between columns
- **Markdown descriptions** -- full GitHub Flavored Markdown with tables, strikethrough, task lists, autolinks, and a live preview toggle
- **Checklists** -- add items, check them off, reorder by dragging, track progress with a visual bar
- **Labels** -- create labels with custom names and colors, toggle them per card
- **Due dates** -- custom calendar picker with relative time display ("in 3 days", "overdue by 2 days")
- **Priority levels** -- None, Low, Medium, High, Urgent -- each with a distinct color indicator visible on card thumbnails
- **Cover colors** -- 10 preset hues rendered as a colored header bar on the card detail
- **File attachments** -- link to files in place or copy them into the board's data directory; open in your system's default application
- **Comments** -- timestamped notes on each card, newest first, with add and delete
- **Card duplication** -- copy a card within its column
- **Card aging** -- cards that haven't been touched in a while gradually fade, so you can see at a glance where work has stalled
### 🔍 Filtering and Search
- **Filter bar** (press `/`) -- narrow down cards by text search, labels, due date status (overdue, today, this week, no date), and priority level
- **Command palette** (`Ctrl+K`) -- fuzzy search across cards in the current board, across all boards, and quick access to app actions like creating a new board or toggling dark mode
- **Cross-board search** -- find any card by title or description across every board you have
### ⌨️ Keyboard Navigation
Full keyboard-driven workflow. Vim-style or arrow keys -- your choice.
| Shortcut | Action |
|---|---|
| `j` / `k` or `↓` / `↑` | Navigate between cards vertically |
| `h` / `l` or `<-` / `->` | Navigate between columns |
| `Enter` | Open the focused card |
| `Escape` | Close modal / clear focus / cancel edit |
| `/` | Toggle filter bar |
| `Ctrl+K` | Open command palette |
| `Ctrl+Z` | Undo |
| `Ctrl+Shift+Z` | Redo |
| `?` | Show all keyboard shortcuts |
### 🎨 Appearance
- **Theme** -- Light, Dark, or follow your system preference
- **Accent color** -- 10 hues (Teal, Blue, Purple, Pink, Red, Orange, Yellow, Lime, Cyan, Slate) applied globally across the interface
- **UI zoom** -- 75% to 150% in 5% increments
- **Density** -- Compact, Comfortable, or Spacious -- adjust how much breathing room the interface gets
- **Board backgrounds** -- None, Dots, Grid, or Gradient pattern per board
- **Default column width** -- configure what width new columns start at
- **Custom scrollbars** -- themed scrollbars throughout, with auto-hide behavior
- **Smooth animations** -- staggered entrances, layout transitions, and micro-interactions powered by Framer Motion, with full `prefers-reduced-motion` support
### 🛡️ Data Safety
Your work is protected by multiple layers of redundancy -- because tools that lose your data don't deserve your trust.
- **Auto-save** -- boards save automatically 500ms after every change
- **Automatic backups** -- timestamped snapshots every 5 minutes, last 10 retained per board
- **Version history** -- browse and restore previous versions from the board settings menu
- **Rolling backup** -- the previous save is always preserved as a `.backup.json` file
- **Portable storage** -- all data lives in a `data/` folder next to the executable; no registry entries, no AppData, no hidden folders
- **Schema validation** -- all data is validated with Zod on every load, with graceful fallback to defaults if a file is corrupted. Forward-compatible: boards from older versions just work.
### 🖥️ Desktop Integration
- **Custom frameless window** -- integrated title bar with minimize, maximize, and close controls, plus a drag region that feels native
- **Window state persistence** -- remembers your window position, size, and maximized state between sessions
- **Due date notifications** -- OS-level desktop notifications for cards that are due today or overdue, checked hourly
- **Open attachments** directly in your system's default application
- **Right-click context menus** -- on boards (duplicate, export, save as template, delete) and on cards (move, duplicate, set priority, delete)
---
## 📥 Getting Started
### Download
Grab `openpylon.exe` from the [Releases](https://git.lashman.live/lashman/openpylon/releases) page. That's it. Unzip, run, done.
No installer. No admin rights. No registry entries. Runs from anywhere -- your desktop, a USB stick, a shared drive. Put it wherever you want. It's yours.
> 💡 **Fully portable** -- OpenPylon stores all its data in a `data/` folder right next to the executable. Move the folder, move your data. Delete the folder, it's gone. No traces left behind.
### Build from Source
Anyone can build this. The source is yours to read, modify, and redistribute.
**Prerequisites:**
- [Node.js](https://nodejs.org/) 18+
- [Rust](https://rustup.rs/) (latest stable)
- [Tauri v2 prerequisites](https://v2.tauri.app/start/prerequisites/) for Windows
```bash
# Clone
git clone https://git.lashman.live/lashman/openpylon.git
cd openpylon
# Install dependencies
npm install
# Development (hot reload)
npm run tauri dev
# Production build
npm run tauri build
```
The portable executable lands in `src-tauri/target/release/openpylon.exe`.
---
## 📂 Data Storage
Everything lives next to the executable. No cloud. No hidden directories. Just files you can see, copy, and control.
```
openpylon.exe
data/
├── settings.json # App preferences
├── boards/
│ ├── 01HXR5K9N2...json # Each board is one JSON file
│ └── ...
├── templates/
│ ├── 01HXR7M3P4...json # Saved board templates
│ └── ...
├── backups/
│ └── 01HXR5K9N2.../
│ ├── 01HXR5K9N2.1708123456.json
│ ├── 01HXR5K9N2.1708123756.json
│ └── ... # Timestamped snapshots (last 10 kept)
└── attachments/
└── 01HXR5K9N2.../
├── diagram.png
└── ... # Copied attachments per board
```
### 📄 Board Format
Each board is a self-contained JSON file with a Zod-validated schema. New fields added in future versions receive sensible defaults on load -- so older board files never break. You can read, edit, or script against these files with any tool you like. They're just JSON. No proprietary formats, no binary blobs, no vendor lock-in.
### 🔄 Backup and Recovery
If something goes wrong:
1. **Version History** -- open board settings (⚙️ icon in the top bar) -> "Version History" -> pick a snapshot -> restore. Your current state is backed up first.
2. **Manual `.backup.json`** -- every board has a `.backup.json` sibling in the `boards/` folder. Rename it to replace the current file.
3. **Timestamped snapshots** -- find the one you want in `data/backups/<board-id>/` and copy it into `data/boards/`.
---
## 🔄 Import and Export
### Importing
Click the **Import** button on the board list screen and pick a `.json` file. OpenPylon auto-detects the format:
| Format | What Gets Imported |
|---|---|
| **OpenPylon JSON** | Everything -- full fidelity round-trip, no data loss |
| **Trello JSON** | Lists -> columns, cards, labels (with color mapping), checklists. Archived/closed items are skipped. |
Migrating off Trello? Export your board from Trello (Menu -> Share -> Export as JSON), then import it here. Your data belongs with you -- not with Atlassian.
### Exporting
Right-click any board card on the board list to export:
| Format | Use Case |
|---|---|
| **JSON** | Full board data. Re-importable into OpenPylon or parseable by any tool. |
| **CSV** | Flat table with board name, column, title, description, labels, due date, checklist progress, and timestamps. Opens in Excel, Sheets, LibreOffice, or anything that reads CSV. |
No lock-in. Take your data wherever you want, whenever you want. We'd rather you have the freedom to leave than the obligation to stay.
---
## ⚙️ Settings Reference
### 🌐 Global Settings
| Setting | Options | Default |
|---|---|---|
| 🎨 Theme | Light · Dark · System | System |
| 🎯 Accent Color | Teal · Blue · Purple · Pink · Red · Orange · Yellow · Lime · Cyan · Slate | Teal |
| 🔎 UI Zoom | 75% - 150% (5% steps) | 100% |
| 📐 Density | Compact · Comfortable · Spacious | Comfortable |
| 📏 Default Column Width | Narrow · Standard · Wide | Standard |
| 🗂️ Board Sort Order | Manual · Name · Created · Modified | Modified |
### 📌 Per-Board Settings
| Setting | Options | Default |
|---|---|---|
| 🖼️ Background | None · Dots · Grid · Gradient | None |
| 📎 Attachment Mode | Link to original · Copy into board | Link |
### 📊 Per-Column Settings
| Setting | Options |
|---|---|
| 📏 Width | Narrow · Standard · Wide |
| 🎨 Color | 10 hues or None |
| 🚦 WIP Limit | None · 3 · 5 · 7 · 10 |
| 📌 Collapsed | Toggle on/off |
---
## 🛠️ Tech Stack
| Layer | Technology |
|---|---|
| 🦀 Runtime | [Tauri v2](https://v2.tauri.app/) -- lightweight, native, no Electron bloat |
| ⚛️ Frontend | [React 19](https://react.dev/) + [TypeScript 5.8](https://www.typescriptlang.org/) |
| 🎨 Styling | [Tailwind CSS 4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/) primitives |
| 🧠 State | [Zustand 5](https://zustand.docs.pmnd.rs/) + [Zundo](https://github.com/charkour/zundo) (50-step undo/redo) |
| 🖱️ Drag & Drop | [dnd-kit](https://dndkit.com/) |
| 🎬 Animation | [Framer Motion](https://www.framer.com/motion/) |
| ✅ Validation | [Zod](https://zod.dev/) |
| 🔣 Icons | [Lucide](https://lucide.dev/) |
| 📝 Markdown | [react-markdown](https://github.com/remarkjs/react-markdown) + [remark-gfm](https://github.com/remarkjs/remark-gfm) |
| 📜 Scrollbars | [OverlayScrollbars](https://kingsora.github.io/OverlayScrollbars/) |
| 🔤 Typography | [Epilogue](https://fonts.google.com/specimen/Epilogue) · [Instrument Serif](https://fonts.google.com/specimen/Instrument+Serif) · [Space Mono](https://fonts.google.com/specimen/Space+Mono) |
All dependencies are free and open-source. No proprietary tooling. No paid services. The entire stack can be audited, forked, and rebuilt by anyone.
---
## 📁 Project Structure
```
openpylon/
├── src/ # React frontend
│ ├── components/
│ │ ├── board/ # Board view -- columns, cards, filter bar, drag overlays
│ │ ├── boards/ # Board list -- grid, new board dialog, board cards
│ │ ├── card-detail/ # Card modal -- markdown, checklists, labels, priority,
│ │ │ # due dates, attachments, comments, cover colors
│ │ ├── command-palette/ # Ctrl+K fuzzy search across everything
│ │ ├── import-export/ # Import/export buttons and file handling
│ │ ├── layout/ # Top bar, window controls, frameless chrome
│ │ ├── settings/ # Settings dialog with tabs
│ │ └── ui/ # Shared primitives (button, dialog, tooltip, popover...)
│ ├── hooks/ # Keyboard shortcuts, keyboard card navigation
│ ├── lib/ # Storage, import/export, board factory, motion presets
│ ├── stores/ # Zustand -- app store, board store, toast store
│ └── types/ # TypeScript interfaces and type definitions
├── src-tauri/ # Tauri / Rust backend
│ ├── src/
│ │ ├── lib.rs # Plugin registration, portable data dir command
│ │ └── main.rs # Entry point
│ ├── capabilities/ # Tauri security permissions
│ ├── icons/ # App icons
│ ├── Cargo.toml # Rust dependencies
│ └── tauri.conf.json # Tauri app configuration
├── docs/plans/ # Design documents and implementation plans
├── package.json
└── README.md # You are here
```
---
## 🧑‍💻 Development
```bash
# Start dev server with hot reload (Vite + Cargo watch)
npm run tauri dev
# Type-check the frontend
npx tsc --noEmit
# Production build (portable exe)
npm run tauri build
```
The dev server runs on `http://localhost:1420` with Vite HMR. Rust backend changes trigger automatic recompilation through Cargo watch.
### 🤝 Contributing
OpenPylon is released into the public domain under CC0 1.0. There's no CLA, no copyright assignment, no gatekeeping. If you want to contribute, just open a PR. If you want to fork it and build something entirely different, go ahead -- no permission needed.
Good things happen when tools are shared freely.
---
## 📄 License
<p align="center">
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
<img src="https://licensebuttons.net/p/zero/1.0/88x31.png" alt="CC0 1.0" />
</a>
</p>
**CC0 1.0 Universal -- Public Domain Dedication**
To the extent possible under law, the authors of OpenPylon have waived all copyright and related rights to this software. This work is published from the United States.
You can copy, modify, distribute, and use this software -- even for commercial purposes -- without asking permission and without owing anyone anything.
See [LICENSE](LICENSE) or https://creativecommons.org/publicdomain/zero/1.0/ for details.
---
<p align="center">
<sub>
Made with care. Shared without conditions.
<br />
Your tools should serve you -- not the other way around.
</sub>
</p>

View File

@@ -0,0 +1,73 @@
# Card Detail Modal Redesign — Design Document
**Date:** 2026-02-15
**Status:** Approved
## Problem
The current card detail modal uses a 60/40 split layout with a massive markdown editor on the left and all metadata (cover, labels, due date, checklist, attachments) crammed into a narrow right sidebar. This is unbalanced — the description field dominates despite being lightly used, while actionable sections like checklist are squeezed.
## Design Decisions (from brainstorming)
| Question | Answer |
|----------|--------|
| Primary use when opening card | Scan everything at once |
| Description importance | Light use (short notes) |
| Modal size | Go wider |
| Layout style | Dashboard grid |
| Section prominence | Equal weight |
| Long checklist behavior | Scroll within cell |
| Title position | Full-width header with cover color |
| Attachment display | Compact list |
| Description visibility | Always visible cell in grid |
## Layout
```
┌──────────────────────────────────────────────────────────┐
│ ██████████████████████████████████████████████████ [×] │ Cover color header
│ Card Title (click to edit) │ Inline-editable
├────────────────────────────┬─────────────────────────────┤
│ LABELS │ DUE DATE │ Row 1: metadata
│ [Tag] [Tag] [Tag] [+] │ Feb 20 · 5 days left │
├────────────────────────────┼─────────────────────────────┤
│ CHECKLIST 2/5 │ DESCRIPTION │ Row 2: content
│ ✓ item 1 │ Short notes here... │
│ ☐ item 2 (scroll) │ (click to edit) │
│ + Add item │ │
├────────────────────────────┼─────────────────────────────┤
│ COVER │ ATTACHMENTS │ Row 3: secondary
│ ○ ○ ○ ○ ○ ○ ○ ○ × │ file.pdf · doc.png │
│ │ [+ Add file] │
└────────────────────────────┴─────────────────────────────┘
```
### Header
- Full-width bar with cover color as background (or neutral `pylon-surface` if no cover)
- White text on colored bg, normal text on surface bg
- Inline-editable title (click → input → Enter/Escape)
- Close [×] button top-right
### Grid Body
- CSS Grid: `grid-template-columns: 1fr 1fr`, `gap: 1rem`
- Each cell: `rounded-lg bg-pylon-column/50 p-4`
- Section headers: `font-mono text-xs uppercase tracking-widest text-pylon-text-secondary`
- Row 1: Labels + Due Date (small metadata)
- Row 2: Checklist + Description (main content, max-h ~200px with internal scroll)
- Row 3: Cover Color + Attachments (secondary)
### Modal
- Width: `max-w-4xl` (up from `max-w-3xl`)
- Max height: `max-h-[85vh]` with body scrollable
- Shared layout animation preserved (`layoutId`)
### Animation
- Grid cells stagger in with `fadeSlideUp` + `staggerContainer(0.05)`
- Backdrop blur + fade (existing)
- Escape/click-outside to close (existing)
## Files to Modify
- `src/components/card-detail/CardDetailModal.tsx` — Full rewrite of layout
- `src/components/card-detail/MarkdownEditor.tsx` — Remove min-h-200, adapt for grid cell
- `src/components/card-detail/ChecklistSection.tsx` — Add max-height + scroll
- Minor: LabelPicker, DueDatePicker, AttachmentSection — No structural changes needed

View File

@@ -0,0 +1,467 @@
# Card Detail Modal Redesign — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the 60/40 split card detail modal with a 2-column dashboard grid where every section gets equal weight.
**Architecture:** Full rewrite of `CardDetailModal.tsx` to use CSS Grid (2 cols, 3 rows) under a cover-color header. Sub-components (`MarkdownEditor`, `ChecklistSection`) get minor tweaks for cell sizing. `CoverColorPicker` moves from inline private component to its own grid cell. Framer Motion stagger preserved.
**Tech Stack:** React 19, TypeScript, Framer Motion 12, Tailwind 4, Zustand
---
### Task 1: Rewrite CardDetailModal — header + grid shell
**Files:**
- Modify: `src/components/card-detail/CardDetailModal.tsx` (full rewrite, lines 1-245)
**Step 1: Replace the entire file with the new layout**
Replace the full contents of `CardDetailModal.tsx` with:
```tsx
import { useState, useEffect, useRef } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import { useBoardStore } from "@/stores/board-store";
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor";
import { ChecklistSection } from "@/components/card-detail/ChecklistSection";
import { LabelPicker } from "@/components/card-detail/LabelPicker";
import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
interface CardDetailModalProps {
cardId: string | null;
onClose: () => void;
}
export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
const card = useBoardStore((s) =>
cardId ? s.board?.cards[cardId] ?? null : null
);
const boardLabels = useBoardStore((s) => s.board?.labels ?? []);
const updateCard = useBoardStore((s) => s.updateCard);
const open = cardId != null && card != null;
return (
<AnimatePresence>
{open && card && cardId && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
{/* Modal */}
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
layoutId={`card-${cardId}`}
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
transition={springs.gentle}
onClick={(e) => e.stopPropagation()}
>
<EscapeHandler onClose={onClose} />
<span className="sr-only">Card detail editor</span>
{/* Header: cover color background + title + close */}
<div
className="relative flex items-center gap-3 px-6 py-4"
style={{
backgroundColor: card.coverColor
? `oklch(55% 0.12 ${card.coverColor})`
: undefined,
}}
>
<InlineTitle
cardId={cardId}
title={card.title}
updateCard={updateCard}
hasColor={card.coverColor != null}
/>
<button
onClick={onClose}
className={`absolute right-4 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors ${
card.coverColor
? "text-white/70 hover:bg-white/20 hover:text-white"
: "text-pylon-text-secondary hover:bg-pylon-column hover:text-pylon-text"
}`}
aria-label="Close"
>
<X className="size-5" />
</button>
</div>
{/* Dashboard grid body */}
<motion.div
className="grid max-h-[calc(85vh-4rem)] grid-cols-2 gap-4 overflow-y-auto p-5"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
{/* Row 1: Labels + Due Date */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<LabelPicker
cardId={cardId}
cardLabelIds={card.labels}
boardLabels={boardLabels}
/>
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
</motion.div>
{/* Row 2: Checklist + Description */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<ChecklistSection
cardId={cardId}
checklist={card.checklist}
/>
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<MarkdownEditor cardId={cardId} value={card.description} />
</motion.div>
{/* Row 3: Cover + Attachments */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CoverColorPicker
cardId={cardId}
coverColor={card.coverColor}
/>
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<AttachmentSection
cardId={cardId}
attachments={card.attachments}
/>
</motion.div>
</motion.div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}
/* ---------- Escape key handler ---------- */
function EscapeHandler({ onClose }: { onClose: () => void }) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return null;
}
/* ---------- Inline editable title ---------- */
interface InlineTitleProps {
cardId: string;
title: string;
updateCard: (cardId: string, updates: { title: string }) => void;
hasColor: boolean;
}
function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setDraft(title);
}, [title]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
function handleSave() {
const trimmed = draft.trim();
if (trimmed && trimmed !== title) {
updateCard(cardId, { title: trimmed });
} else {
setDraft(title);
}
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
setDraft(title);
setEditing(false);
}
}
const textColor = hasColor ? "text-white" : "text-pylon-text";
const hoverColor = hasColor ? "hover:text-white/80" : "hover:text-pylon-accent";
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${
hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent"
}`}
/>
);
}
return (
<h2
onClick={() => setEditing(true)}
className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
>
{title}
</h2>
);
}
/* ---------- Cover color picker ---------- */
function CoverColorPicker({
cardId,
coverColor,
}: {
cardId: string;
coverColor: string | null;
}) {
const updateCard = useBoardStore((s) => s.updateCard);
const presets = [
{ hue: "160", label: "Teal" },
{ hue: "240", label: "Blue" },
{ hue: "300", label: "Purple" },
{ hue: "350", label: "Pink" },
{ hue: "25", label: "Red" },
{ hue: "55", label: "Orange" },
{ hue: "85", label: "Yellow" },
{ hue: "130", label: "Lime" },
{ hue: "200", label: "Cyan" },
{ hue: "0", label: "Slate" },
];
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Cover
</h4>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => updateCard(cardId, { coverColor: null })}
className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
title="None"
>
&times;
</button>
{presets.map(({ hue, label }) => (
<button
key={hue}
onClick={() => updateCard(cardId, { coverColor: hue })}
className="size-6 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: `oklch(55% 0.12 ${hue})`,
outline:
coverColor === hue ? "2px solid currentColor" : "none",
outlineOffset: "1px",
}}
title={label}
/>
))}
</div>
</div>
);
}
```
**Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 3: Commit**
```bash
git add src/components/card-detail/CardDetailModal.tsx
git commit -m "feat: rewrite card detail modal as 2-column dashboard grid"
```
---
### Task 2: Adapt MarkdownEditor for grid cell
**Files:**
- Modify: `src/components/card-detail/MarkdownEditor.tsx` (lines 93-103)
**Step 1: Replace `min-h-[200px]` with cell-friendly sizing**
In `MarkdownEditor.tsx`, change the textarea className (line 99):
```
Old: className="min-h-[200px] w-full resize-y rounded-md ...
New: className="min-h-[100px] max-h-[160px] w-full resize-y rounded-md ...
```
And change the preview container className (line 103):
```
Old: className="min-h-[200px] cursor-pointer rounded-md ...
New: className="min-h-[100px] max-h-[160px] overflow-y-auto cursor-pointer rounded-md ...
```
**Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 3: Commit**
```bash
git add src/components/card-detail/MarkdownEditor.tsx
git commit -m "feat: adapt markdown editor sizing for dashboard grid cell"
```
---
### Task 3: Add scroll containment to ChecklistSection
**Files:**
- Modify: `src/components/card-detail/ChecklistSection.tsx` (lines 52-64)
**Step 1: Add max-height + overflow to the checklist items container**
In `ChecklistSection.tsx`, change the items container (line 53):
```
Old: <div className="flex flex-col gap-1">
New: <div className="flex max-h-[160px] flex-col gap-1 overflow-y-auto">
```
Also add a small progress bar under the header. Change lines 39-50 (the header section) to:
```tsx
{/* Header + progress */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Checklist
</h4>
{checklist.length > 0 && (
<span className="font-mono text-xs text-pylon-text-secondary">
{checked}/{checklist.length}
</span>
)}
</div>
{checklist.length > 0 && (
<div className="h-1 w-full overflow-hidden rounded-full bg-pylon-column">
<div
className="h-full rounded-full bg-pylon-accent transition-all duration-300"
style={{ width: `${(checked / checklist.length) * 100}%` }}
/>
</div>
)}
</div>
```
**Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 3: Commit**
```bash
git add src/components/card-detail/ChecklistSection.tsx
git commit -m "feat: add scroll containment and progress bar to checklist"
```
---
### Task 4: Remove unused Separator import + visual verification
**Files:**
- Modify: `src/components/card-detail/CardDetailModal.tsx` (verify no stale imports)
**Step 1: Verify the file has no unused imports**
The rewrite in Task 1 already removed the `Separator` import. Confirm the import block does NOT include:
- `import { Separator } from "@/components/ui/separator";`
If it's still there, delete it.
**Step 2: Run TypeScript check**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 3: Run the dev server and visually verify**
Run: `npm run dev` (or `npx tauri dev` if checking in Tauri)
Verify:
- Card detail modal opens on card click
- Full-width header shows cover color (or neutral bg)
- Title is editable (click to edit, Enter/Escape)
- Close button [x] works
- 2x3 grid: Labels | Due Date / Checklist | Description / Cover | Attachments
- Each cell has rounded-lg background
- Checklist scrolls when > ~6 items
- Description shows compact preview
- All animations stagger in
- Escape closes modal
- Click outside closes modal
**Step 4: Commit**
```bash
git add -A
git commit -m "feat: card detail modal dashboard grid redesign complete"
```

View File

@@ -0,0 +1,87 @@
# Custom Date Picker — Design Document
**Date:** 2026-02-15
**Status:** Approved
## Problem
The DueDatePicker uses a native `<input type="date">` which looks out of place in the app's custom dark theme with OKLCH colors. Need a fully custom calendar widget that matches the design language.
## Design Decisions
| Question | Answer |
|----------|--------|
| Trigger | Click the entire due date grid cell |
| Calendar position | Popover floating below the cell |
| Navigation | Month + year dropdown selectors |
| Today button | Yes, at bottom of calendar |
| Past dates | Selectable but dimmed |
| Clear action | Both: x on cell display AND Clear button in calendar footer |
| Approach | Fully custom (date-fns + Radix Popover, no new deps) |
## Layout
```
┌─ Due Date Grid Cell ──────────────────────┐
│ DUE DATE [×] │
│ Feb 20, 2026 · in 5 days │
└───────────────────────────────────────────┘
▼ popover (280px wide)
┌───────────────────────────────────────┐
│ ◀ [February ▾] [2026 ▾] ▶ │
├───────────────────────────────────────┤
│ Mo Tu We Th Fr Sa Su │
│ ·· ·· ·· ·· ·· 1 2 │
│ 3 4 5 6 7 8 9 │
│ 10 11 12 13 14 ⬤15 16 │
│ 17 18 19 ■20 21 22 23 │
│ 24 25 26 27 28 ·· ·· │
├───────────────────────────────────────┤
│ [Today] [Clear] │
└───────────────────────────────────────┘
```
## Components
### DueDatePicker (modified)
- Remove `<input type="date">` entirely
- Cell display: formatted date + relative time, or placeholder
- × clear button in section header (visible when date is set)
- Clicking cell body opens CalendarPopover
- Overdue dates in `text-pylon-danger`
### CalendarPopover (new)
- Radix Popover anchored below the cell
- 280px wide, `bg-pylon-surface rounded-xl shadow-2xl`
#### Header
- Left/right arrow buttons for prev/next month
- Clickable month name → month selector (3×4 grid of month names)
- Clickable year → year selector (grid of years, current ±5)
#### Day Grid
- 7 columns (Mo-Su), 6 rows max
- Day cells: `size-9 text-sm rounded-lg`
- Selected: `bg-pylon-accent text-white`
- Today: `ring-1 ring-pylon-accent`
- Past: `opacity-50`
- Other month days: hidden (empty cells)
- Hover: `bg-pylon-column`
#### Footer
- "Today" button (left) — jumps to and selects today
- "Clear" button (right) — removes due date
### Animation
- Popover: `scaleIn` + `springs.snappy`
- Month/year selector: `AnimatePresence mode="wait"` crossfade
## Files
- Create: `src/components/card-detail/CalendarPopover.tsx`
- Modify: `src/components/card-detail/DueDatePicker.tsx`
## Dependencies
- date-fns v4 (already installed): `startOfMonth`, `endOfMonth`, `startOfWeek`, `endOfWeek`, `eachDayOfInterval`, `format`, `isSameDay`, `isSameMonth`, `isToday`, `isPast`, `addMonths`, `subMonths`, `setMonth`, `setYear`, `getYear`
- Radix Popover (already installed via `src/components/ui/popover.tsx`)
- Framer Motion (already installed)

View File

@@ -0,0 +1,456 @@
# Custom Date Picker — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the native HTML date input with a fully custom calendar widget that matches the app's dark OKLCH theme.
**Architecture:** Create a new `CalendarPopover` component (calendar grid + month/year selectors + footer) using date-fns for date math and Radix Popover for positioning. Rewrite `DueDatePicker` to use it instead of `<input type="date">`. No new dependencies.
**Tech Stack:** React 19, TypeScript, date-fns v4, Framer Motion 12, Tailwind 4, Radix Popover
---
### Task 1: Create CalendarPopover component
**Files:**
- Create: `src/components/card-detail/CalendarPopover.tsx`
**Step 1: Create the file with the complete component**
Create `src/components/card-detail/CalendarPopover.tsx` with:
```tsx
import { useState, useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameDay,
isSameMonth,
isToday as isTodayFn,
isPast,
addMonths,
subMonths,
setMonth,
setYear,
getYear,
getMonth,
} from "date-fns";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { springs } from "@/lib/motion";
interface CalendarPopoverProps {
selectedDate: Date | null;
onSelect: (date: Date) => void;
onClear: () => void;
children: React.ReactNode;
}
type ViewMode = "days" | "months" | "years";
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
export function CalendarPopover({
selectedDate,
onSelect,
onClear,
children,
}: CalendarPopoverProps) {
const [open, setOpen] = useState(false);
const [viewDate, setViewDate] = useState(() => selectedDate ?? new Date());
const [viewMode, setViewMode] = useState<ViewMode>("days");
// Reset view when opening
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
setViewDate(selectedDate ?? new Date());
setViewMode("days");
}
setOpen(nextOpen);
}
function handleSelectDate(date: Date) {
onSelect(date);
setOpen(false);
}
function handleToday() {
const today = new Date();
onSelect(today);
setOpen(false);
}
function handleClear() {
onClear();
setOpen(false);
}
// Build the 6×7 grid of days for the current viewDate month
const calendarDays = useMemo(() => {
const monthStart = startOfMonth(viewDate);
const monthEnd = endOfMonth(viewDate);
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [viewDate]);
// Year range for year selector: current year ± 5
const yearRange = useMemo(() => {
const center = getYear(viewDate);
const years: number[] = [];
for (let y = center - 5; y <= center + 5; y++) {
years.push(y);
}
return years;
}, [viewDate]);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-[280px] bg-pylon-surface p-0 rounded-xl shadow-2xl border-border"
>
{/* Navigation header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<Button
variant="ghost"
size="icon-xs"
onClick={() => setViewDate((d) => subMonths(d, 1))}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ChevronLeft className="size-4" />
</Button>
<div className="flex items-center gap-1">
<button
onClick={() => setViewMode(viewMode === "months" ? "days" : "months")}
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
>
{format(viewDate, "MMMM")}
</button>
<button
onClick={() => setViewMode(viewMode === "years" ? "days" : "years")}
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
>
{format(viewDate, "yyyy")}
</button>
</div>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setViewDate((d) => addMonths(d, 1))}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ChevronRight className="size-4" />
</Button>
</div>
{/* Body: days / months / years */}
<div className="p-3">
<AnimatePresence mode="wait">
{viewMode === "days" && (
<motion.div
key="days"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Weekday headers */}
<div className="mb-1 grid grid-cols-7">
{WEEKDAYS.map((wd) => (
<div
key={wd}
className="flex h-8 items-center justify-center font-mono text-[10px] uppercase tracking-wider text-pylon-text-secondary"
>
{wd}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{calendarDays.map((day) => {
const inMonth = isSameMonth(day, viewDate);
const today = isTodayFn(day);
const selected = selectedDate != null && isSameDay(day, selectedDate);
const past = isPast(day) && !today;
if (!inMonth) {
return <div key={day.toISOString()} className="h-9" />;
}
return (
<button
key={day.toISOString()}
onClick={() => handleSelectDate(day)}
className={`flex h-9 items-center justify-center rounded-lg text-sm transition-colors
${selected
? "bg-pylon-accent font-medium text-white"
: today
? "font-medium text-pylon-accent ring-1 ring-pylon-accent"
: past
? "text-pylon-text opacity-50 hover:bg-pylon-column hover:opacity-75"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{format(day, "d")}
</button>
);
})}
</div>
</motion.div>
)}
{viewMode === "months" && (
<motion.div
key="months"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="grid grid-cols-3 gap-1"
>
{MONTH_NAMES.map((name, i) => (
<button
key={name}
onClick={() => {
setViewDate((d) => setMonth(d, i));
setViewMode("days");
}}
className={`rounded-lg py-2 text-sm transition-colors ${
getMonth(viewDate) === i
? "bg-pylon-accent font-medium text-white"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{name}
</button>
))}
</motion.div>
)}
{viewMode === "years" && (
<motion.div
key="years"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="grid grid-cols-3 gap-1"
>
{yearRange.map((year) => (
<button
key={year}
onClick={() => {
setViewDate((d) => setYear(d, year));
setViewMode("days");
}}
className={`rounded-lg py-2 text-sm transition-colors ${
getYear(viewDate) === year
? "bg-pylon-accent font-medium text-white"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{year}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={handleToday}
className="text-pylon-accent hover:text-pylon-accent"
>
Today
</Button>
<Button
variant="ghost"
size="xs"
onClick={handleClear}
className="text-pylon-text-secondary hover:text-pylon-danger"
>
Clear
</Button>
</div>
</PopoverContent>
</Popover>
);
}
```
**Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 3: Commit**
```bash
git add src/components/card-detail/CalendarPopover.tsx
git commit -m "feat: create custom CalendarPopover component"
```
---
### Task 2: Rewrite DueDatePicker to use CalendarPopover
**Files:**
- Modify: `src/components/card-detail/DueDatePicker.tsx` (full rewrite)
**Step 1: Replace the entire file**
Replace `src/components/card-detail/DueDatePicker.tsx` with:
```tsx
import { format, formatDistanceToNow, isPast, isToday } from "date-fns";
import { X } from "lucide-react";
import { useBoardStore } from "@/stores/board-store";
import { CalendarPopover } from "@/components/card-detail/CalendarPopover";
interface DueDatePickerProps {
cardId: string;
dueDate: string | null;
}
export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
const updateCard = useBoardStore((s) => s.updateCard);
const dateObj = dueDate ? new Date(dueDate) : null;
const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj);
function handleSelect(date: Date) {
updateCard(cardId, { dueDate: format(date, "yyyy-MM-dd") });
}
function handleClear() {
updateCard(cardId, { dueDate: null });
}
return (
<div className="flex flex-col gap-2">
{/* Header with clear button */}
<div className="flex items-center justify-between">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Due Date
</h4>
{dueDate && (
<button
onClick={handleClear}
className="rounded p-0.5 text-pylon-text-secondary transition-colors hover:bg-pylon-danger/10 hover:text-pylon-danger"
aria-label="Clear due date"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* Clickable date display → opens calendar */}
<CalendarPopover
selectedDate={dateObj}
onSelect={handleSelect}
onClear={handleClear}
>
<button className="flex w-full items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-pylon-column/60">
{dateObj ? (
<>
<span
className={`text-sm font-medium ${
overdue ? "text-pylon-danger" : "text-pylon-text"
}`}
>
{format(dateObj, "MMM d, yyyy")}
</span>
<span
className={`text-xs ${
overdue ? "text-pylon-danger" : "text-pylon-text-secondary"
}`}
>
{overdue
? `overdue by ${formatDistanceToNow(dateObj)}`
: isToday(dateObj)
? "today"
: `in ${formatDistanceToNow(dateObj)}`}
</span>
</>
) : (
<span className="text-sm italic text-pylon-text-secondary/60">
Click to set date...
</span>
)}
</button>
</CalendarPopover>
</div>
);
}
```
**Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 3: Commit**
```bash
git add src/components/card-detail/DueDatePicker.tsx
git commit -m "feat: rewrite DueDatePicker to use custom CalendarPopover"
```
---
### Task 3: Visual verification and final commit
**Step 1: Run TypeScript check**
Run: `npx tsc --noEmit`
Expected: No errors
**Step 2: Run the dev server**
Run: `npx tauri dev`
Verify:
- Open a card → Due Date cell shows "Click to set date..." or the current date
- Click the cell → calendar popover appears below
- Calendar shows correct month with today highlighted (ring)
- Click a date → it's selected (filled accent), popover closes, cell shows formatted date
- Click month name → month selector grid appears, click a month → returns to days
- Click year → year selector grid appears, click a year → returns to days
- Left/right arrows navigate months
- "Today" button selects today and closes
- "Clear" button in popover footer removes the date and closes
- × button in cell header clears the date without opening calendar
- Past dates are dimmed but clickable
- Overdue dates show in red
**Step 3: Commit**
```bash
git add -A
git commit -m "feat: custom date picker with calendar popover complete"
```

View File

@@ -0,0 +1,188 @@
# Motion, Dark Mode & Custom Titlebar Design
**Date:** 2026-02-15
**Goal:** Add playful bouncy animations everywhere, lighten dark mode for HDR monitors, and implement custom window decorations merged into the TopBar
**Approach:** Centralized motion system + CSS variable tuning + Tauri decoration override
---
## 1. Motion System Foundation
Create `src/lib/motion.ts` — shared animation config imported by all components.
### Spring Presets (Bouncy Profile)
- **bouncy** — stiffness: 400, damping: 15, mass: 0.8 (main preset, visible overshoot)
- **snappy** — stiffness: 500, damping: 20 (micro-interactions — buttons, toggles)
- **gentle** — stiffness: 200, damping: 20 (larger elements — modals, page transitions)
- **wobbly** — stiffness: 300, damping: 10 (playful emphasis — toasts, notifications)
### Reusable Variants
- `fadeSlideUp` — enters from below with opacity fade (cards, list items)
- `fadeSlideDown` — enters from above (dropdowns, menus)
- `scaleIn` — scales from 0.9 to 1 with bounce (modals, popovers)
- `staggerContainer` — parent variant that staggers children
### Stagger Helper
`staggerChildren(delay = 0.04)` — generates parent transition variants for cascading entrances.
---
## 2. Component-by-Component Motion Rollout
### Page Transitions (App.tsx)
- Wrap view switch in `AnimatePresence mode="wait"`
- Board-list exits with fade+slide-left, board enters with fade+slide-right
- Uses `gentle` spring
### Board List (BoardList.tsx)
- Board cards stagger in on mount using `staggerContainer`
- Each BoardCard uses `fadeSlideUp` entrance
- Empty state fades in
### Board View (BoardView.tsx)
- Columns stagger in from left to right on mount (0.06s delay each)
- Cards within each column stagger in (0.03s delay)
### Card Thumbnails (CardThumbnail.tsx)
- Migrate existing spring to shared `bouncy` preset
- `whileHover` scale 1.02 with shadow elevation
- `whileTap` scale 0.98
### Card Detail Modal (CardDetailModal.tsx)
- **Shared layout animation** — CardThumbnail gets `layoutId={card-${card.id}}`, modal wrapper gets same layoutId
- Card morphs into the modal on open — hero transition
- Backdrop blurs in with animated opacity + backdropFilter
- Modal content sections stagger in after layout animation
### Column Header (ColumnHeader.tsx)
- Dropdown menu items stagger in with `fadeSlideDown`
### TopBar
- Buttons have `whileHover` and `whileTap` micro-animations
- Saving status text fades in/out with AnimatePresence
### Toast Notifications (ToastContainer.tsx)
- Migrate to `wobbly` spring for extra personality
- Exit slides down + fades
### Settings Dialog
- Tab content crossfades with AnimatePresence
- Accent color swatches have `whileHover` scale pulse
### Command Palette
- Results stagger in as you type
---
## 3. Gesture-Reactive Drag & Drop
Override dnd-kit's default drag overlay with Framer Motion-powered custom overlay.
- **On drag start:** Card lifts with `scale: 1.05`, box-shadow, slight rotate based on grab offset
- **During drag:** Card tilts based on pointer velocity (useMotionValue + useTransform). Max tilt: ~5 degrees
- **On drop:** Spring back to `scale: 1, rotate: 0`. Target column cards spring apart using `layout` prop
- dnd-kit handles position/sorting logic; we layer gesture transforms on top
---
## 4. Dark Mode — Subtle Lift for HDR
### Pylon Dark Variables (in `.dark {}`)
| Variable | Current | New |
|----------|---------|-----|
| `--pylon-bg` | `oklch(18% 0.01 50)` | `oklch(25% 0.012 50)` |
| `--pylon-surface` | `oklch(22% 0.01 50)` | `oklch(29% 0.012 50)` |
| `--pylon-column` | `oklch(20% 0.012 50)` | `oklch(27% 0.014 50)` |
| `--pylon-text` | `oklch(90% 0.01 50)` | `oklch(92% 0.01 50)` |
| `--pylon-text-secondary` | `oklch(55% 0.01 50)` | `oklch(58% 0.01 50)` |
| `--pylon-accent` | `oklch(60% 0.12 160)` | `oklch(62% 0.13 160)` |
| `--pylon-danger` | `oklch(60% 0.18 25)` | `oklch(62% 0.18 25)` |
### Shadcn Dark Variables
| Variable | Current | New |
|----------|---------|-----|
| `--background` | `oklch(0.145 0 0)` | `oklch(0.22 0 0)` |
| `--card`, `--popover` | `oklch(0.205 0 0)` | `oklch(0.27 0 0)` |
| `--secondary`, `--muted`, `--accent` | `oklch(0.269 0 0)` | `oklch(0.32 0 0)` |
| `--border` | `oklch(1 0 0 / 10%)` | `oklch(1 0 0 / 12%)` |
| `--input` | `oklch(1 0 0 / 15%)` | `oklch(1 0 0 / 18%)` |
| `--sidebar` | `oklch(0.205 0 0)` | `oklch(0.27 0 0)` |
| `--sidebar-accent` | `oklch(0.269 0 0)` | `oklch(0.32 0 0)` |
Color scheme (hue 50 warmth) preserved. Slight chroma bump for HDR vibrancy.
---
## 5. Custom Window Titlebar
### Tauri Configuration
Set `"decorations": false` in `tauri.conf.json` to remove native OS titlebar.
### TopBar Integration
Window controls added to far right of existing TopBar, after a thin vertical separator:
```
[Back] .......... [Board Title] .......... [Undo][Redo][Settings][Save][Search][Gear] | [—][□][×]
```
### WindowControls Component
Inline in TopBar or extracted to `src/components/layout/WindowControls.tsx`.
**Buttons:**
- Minimize: `Minus` icon from Lucide → `getCurrentWindow().minimize()`
- Maximize/Restore: `Square` / `Copy` icon → `getCurrentWindow().toggleMaximize()`
- Close: `X` icon → `getCurrentWindow().close()`
**Styling:**
- 32x32px hit area, 16x16px icons
- Default: `text-pylon-text-secondary`
- Hover: Background with accent at 10% opacity
- Close hover: `pylon-danger` at 15% opacity (red highlight — convention)
- All have Framer Motion `whileHover` and `whileTap` springs
**State tracking:**
- Listen to `getCurrentWindow().onResized()` for maximize state
- Query `isMaximized()` on mount for initial icon
- `data-tauri-drag-region` stays on header; window buttons do NOT propagate drag
---
## Files Affected
### New Files
- `src/lib/motion.ts` — shared spring presets, variants, helpers
### Modified Files
- `src/App.tsx` — AnimatePresence page transitions
- `src/index.css` — dark mode color value updates
- `src/components/layout/TopBar.tsx` — window controls, motion on buttons
- `src/components/layout/AppShell.tsx` — support page transition wrapper
- `src/components/boards/BoardList.tsx` — stagger animation on board cards
- `src/components/boards/BoardCard.tsx` — fadeSlideUp entrance, hover/tap
- `src/components/board/BoardView.tsx` — column stagger, gesture-reactive drag overlay
- `src/components/board/KanbanColumn.tsx` — card stagger, layout animation for reorder
- `src/components/board/CardThumbnail.tsx` — shared layoutId, bouncy preset, hover/tap
- `src/components/board/ColumnHeader.tsx` — dropdown animation
- `src/components/card-detail/CardDetailModal.tsx` — shared layout animation (hero), content stagger
- `src/components/toast/ToastContainer.tsx` — wobbly spring
- `src/components/settings/SettingsDialog.tsx` — tab crossfade, swatch hover
- `src/components/command-palette/CommandPalette.tsx` — result stagger
- `src/components/shortcuts/ShortcutHelpModal.tsx` — entrance animation
- `src-tauri/tauri.conf.json` — decorations: false
## Implementation Order
1. Motion system foundation (`src/lib/motion.ts`)
2. Dark mode CSS variable updates
3. Custom titlebar (Tauri config + WindowControls)
4. Page transitions (App.tsx + AnimatePresence)
5. Board list animations (stagger + BoardCard)
6. Board view column stagger + card stagger
7. Card thumbnail hover/tap + shared layoutId
8. Card detail modal shared layout animation
9. Gesture-reactive drag overlay
10. Micro-interactions (TopBar, ColumnHeader dropdowns)
11. Toast, Settings, Command Palette, ShortcutHelp animations
12. Polish pass — verify all springs feel cohesive, test reduced-motion

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
# OpenPylon Visual Glow-Up Design
**Date:** 2026-02-15
**Goal:** Transform OpenPylon from functional-but-bare to visually polished and delightful
**Approach:** Settings-first foundation — build the settings infrastructure, then layer visual features on top
---
## 1. Settings Model & Infrastructure
Expand `AppSettings` in `src/types/settings.ts`:
```typescript
export interface AppSettings {
theme: "light" | "dark" | "system";
dataDirectory: string | null;
recentBoardIds: string[];
// Appearance
accentColor: string; // OKLCH hue (0-360), default "160" (teal)
uiZoom: number; // 0.75-1.5, default 1.0
density: "compact" | "comfortable" | "spacious";
// Board defaults
defaultColumnWidth: ColumnWidth; // default "standard"
}
```
### Zoom
Set `font-size` on `<html>` to `uiZoom * 16px`. Everything uses `rem` via Tailwind, so the entire UI scales proportionally.
### Accent Color
Store an OKLCH hue value. On apply, regenerate `--pylon-accent` as `oklch(55% 0.12 {hue})` (light) / `oklch(60% 0.12 {hue})` (dark).
### Density
Set CSS custom property `--density-factor` (compact=0.75, comfortable=1.0, spacious=1.25). Use it to scale padding on columns, cards, and gaps.
### App Store Changes
Add `setAccentColor`, `setUiZoom`, `setDensity`, `setDefaultColumnWidth` actions to `app-store.ts`. Each saves immediately (no Save button). Add `applyAppearance()` function that applies zoom, accent, and density to the DOM — called on init and on any change.
---
## 2. Settings Panel UI
Transform `SettingsDialog.tsx` from a tiny modal into a tabbed panel.
- Widen to `sm:max-w-lg`
- 4 tabs: **Appearance** | **Boards** | **Keyboard Shortcuts** | **About**
- Simple button-based tab navigation (no library needed)
### Appearance Tab
- **Theme** — existing 3-button toggle (unchanged)
- **UI Zoom** — slider 75%-150% in 5% steps, live preview, reset button, shows current %
- **Accent Color** — 10 preset OKLCH hue swatches: teal/160, blue/240, purple/300, pink/350, red/25, orange/55, yellow/85, lime/130, cyan/200, slate/achromatic. Click to apply immediately.
- **Density** — 3-button toggle: Compact / Comfortable / Spacious
### Boards Tab
- **Default column width** — 3-button toggle: Narrow / Standard / Wide
### Keyboard Shortcuts Tab
- Read-only reference table, two-column: key combo (mono font) | description
- All shortcuts: Ctrl+K, Ctrl+Z, Ctrl+Y, Ctrl+N, ?, etc.
### About Tab
- App name, version, tagline
- Link to repo (opens via Tauri shell)
---
## 3. Board Color Applied to UI
Currently `board.color` only shows on BoardCard in the home screen.
- **TopBar:** 2px bottom border in board color when viewing a board. Color dot next to board title.
- **Column headers:** 3px top-border in board color at 30% opacity.
- **No full background tinting** — structural accents only (borders, dots).
---
## 4. Column Colors
Extend `Column` interface:
```typescript
export interface Column {
id: string;
title: string;
cardIds: string[];
width: ColumnWidth;
color: string | null; // optional OKLCH hue, null = use board color
}
```
- Set via "Color" submenu in ColumnHeader dropdown (same 10 swatches + "None")
- Column's 3px top-border uses column color when set, falls back to board color
- Column background stays neutral
---
## 5. Card Cover Colors
Extend `Card` interface:
```typescript
export interface Card {
// ...existing
coverColor: string | null; // OKLCH hue for color strip
}
```
- No image uploads for v1 — just a color bar
- 4px colored bar at top of CardThumbnail
- Set via swatch picker in CardDetailModal
- Simple CSS, no layout disruption
---
## 6. Richer Card Thumbnails
Add to existing CardThumbnail footer row:
- **Attachment indicator** — paperclip icon + count (if `attachments.length > 0`)
- **Description indicator** — text-lines icon (if `description` is non-empty)
- **Cover color bar** — from Section 5
No priority badges or assignees — keeping thumbnails clean.
---
## 7. Toast Notification System
- `useToastStore` — Zustand store: `{ id, message, type }[]`
- `<ToastContainer>` in App.tsx — fixed bottom-right, pills with auto-dismiss (3s + fade)
- Types: `success` (green), `error` (red), `info` (neutral)
- Fires on: board deleted, board exported, board imported, import failed, save error
---
## 8. Undo/Redo Buttons in TopBar
- Two icon buttons: RotateCcw (undo) and RotateCw (redo)
- Placed in TopBar right section, before command palette button
- Disabled when at start/end of history
- Only visible in board view
- Tooltips show keyboard shortcuts (Ctrl+Z / Ctrl+Y)
---
## 9. Keyboard Shortcut Help Modal
- Triggered by `?` key (when not in an input/textarea)
- Two-column grid grouped by category: Navigation, Board, Cards
- Same data as Settings keyboard shortcuts tab
- Lightweight modal, dismissible with Escape or clicking outside
---
## 10. Board Backgrounds
Extend `BoardSettings`:
```typescript
export interface BoardSettings {
attachmentMode: "link" | "copy";
background: "none" | "dots" | "grid" | "gradient";
}
```
- **none** — plain (current)
- **dots** — subtle radial-gradient dot pattern, 5% opacity
- **grid** — subtle grid lines via CSS
- **gradient** — soft gradient using board color at 3-5% opacity
- Set via board settings dropdown (gear icon in TopBar when viewing a board)
---
## 11. Onboarding / Empty States
- **First launch (zero boards):** Upgraded empty state — welcoming message, prominent "Create Board" button, secondary "Import Board" option
- **Empty column:** Dashed-border area with "Drop or add a card" text
- **Empty description:** "Click to add a description..." placeholder
- **Empty checklist:** "Add your first item..." when empty
---
## 12. Polish Pass
- Consistent hover transitions (200ms ease) across all interactive elements
- Verify focus rings work with all accent colors
- Test Framer Motion springs at different zoom levels
- Dark mode testing for all new features (column colors, card covers, backgrounds)
- Thin, themed scrollbars on column scroll areas
---
## Implementation Order
1. Settings model + app store actions + CSS variable application
2. Settings panel UI (tabbed, all sections)
3. UI zoom
4. Accent color
5. Density toggle
6. Board color in UI (TopBar + column headers)
7. Column colors
8. Card cover colors
9. Richer card thumbnails
10. Toast notification system
11. Undo/redo buttons
12. Keyboard shortcut help modal
13. Board backgrounds
14. Onboarding / empty states
15. Polish pass
## Files Affected
- `src/types/settings.ts` — expanded AppSettings
- `src/types/board.ts` — Column.color, Card.coverColor, BoardSettings.background
- `src/stores/app-store.ts` — new actions, applyAppearance()
- `src/components/settings/SettingsDialog.tsx` — full rewrite (tabbed)
- `src/index.css` — density variables, zoom hook, background patterns
- `src/components/layout/TopBar.tsx` — board color, undo/redo buttons, board settings gear
- `src/components/board/KanbanColumn.tsx` — column color border
- `src/components/board/ColumnHeader.tsx` — color submenu
- `src/components/board/CardThumbnail.tsx` — cover bar, attachment/description indicators
- `src/components/card-detail/CardDetailModal.tsx` — cover color picker
- `src/components/board/BoardView.tsx` — background patterns
- `src/App.tsx` — ToastContainer, shortcut help modal, appearance init
- `src/stores/toast-store.ts` — NEW
- `src/components/toast/ToastContainer.tsx` — NEW
- `src/components/shortcuts/ShortcutHelpModal.tsx` — NEW
- `src/stores/board-store.ts` — new actions for column color, card cover
- `src/lib/board-factory.ts` — defaults for new fields
- `src/lib/schemas.ts` — migration for new fields
- `src/components/boards/BoardList.tsx` — upgraded empty state
- `src/hooks/useKeyboardShortcuts.ts` — ? key handler

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
# OpenPylon: 15 Improvements Design
## Overview
15 improvements to OpenPylon organized into 5 phases (0-4), designed for incremental delivery. Each phase builds on the previous. You can ship after any phase and have a coherent improvement.
## Decisions
- **Phasing**: 4 phases (quick wins first, progressively bigger features)
- **Data compatibility**: New fields use Zod `.default()` values. No migration code. Old boards load cleanly.
- **Templates storage**: JSON files in `data/templates/`
- **Backup storage**: Timestamped files in `data/backups/{boardId}/`, keep last 10
---
## Phase 0: Data Model Foundation
All type/schema changes that later features depend on. Done first so everything builds on a stable base.
### Card type additions
```typescript
// Added to Card interface + cardSchema
priority: "none" | "low" | "medium" | "high" | "urgent"; // default: "none"
comments: Comment[]; // default: []
// New type + schema
interface Comment {
id: string;
text: string;
createdAt: string;
}
```
### Column type additions
```typescript
// Added to Column interface + columnSchema
collapsed: boolean; // default: false
wipLimit: number | null; // default: null
```
### Files touched
- `src/types/board.ts` — Add fields to Card, Column interfaces. Add Comment interface.
- `src/lib/schemas.ts` — Add Zod fields with defaults to cardSchema, columnSchema. Add commentSchema.
---
## Phase 1: Quick Wins
Minimal changes, high value. ~4 files each.
### #8 — Consume defaultColumnWidth Setting
In `board-store.ts`, `addColumn` reads `useAppStore.getState().settings.defaultColumnWidth` instead of hardcoding `"standard"`.
**Files**: `src/stores/board-store.ts` (1 line change)
### #4 — Due Date Visual Indicators
Replace binary overdue/not logic in `CardThumbnail` with 4-tier color system:
| Status | Condition | Color |
|--------|-----------|-------|
| Overdue | past + not today | `pylon-danger` (red) |
| Approaching | due within 2 days | amber `oklch(65% 0.15 70)` |
| Comfortable | due but >2 days | green `oklch(55% 0.12 145)` |
| No date | null | `pylon-text-secondary` (gray) |
Helper function `getDueDateStatus(dueDate: string | null)` returns `{ color, label }`.
**Files**: `src/components/board/CardThumbnail.tsx`
### #9 — Card Aging Visualization
Compute days since `card.updatedAt`. Apply opacity:
| Days stale | Opacity |
|------------|---------|
| 0-7 | 1.0 |
| 7-14 | 0.85 |
| 14-30 | 0.7 |
| 30+ | 0.55 |
Applied as inline `opacity` on the card `motion.button`.
**Files**: `src/components/board/CardThumbnail.tsx`
### #12 — Open Attachments
Add "Open" button to each attachment in `AttachmentSection`. Uses `open()` from `@tauri-apps/plugin-opener` (already registered).
**Files**: `src/components/card-detail/AttachmentSection.tsx`
---
## Phase 2: Card Interactions & UI Enhancements
5 features that transform how cards feel to use.
### #2 — Card Priority Levels
**Thumbnail indicator**: Colored dot in footer row. Color map:
- `none`: hidden
- `low`: blue
- `medium`: yellow
- `high`: orange
- `urgent`: red with pulse animation
**Detail modal**: Priority picker section in left column (like LabelPicker). Row of 5 clickable chips with colors.
**Files**: `CardThumbnail.tsx`, `CardDetailModal.tsx` (new PriorityPicker component inline or separate), `board-store.ts` (no new action needed — `updateCard` handles it)
### #5 — Card Context Menu
Wrap `CardThumbnail` in Radix `ContextMenu`.
**Menu items**:
- Move to → (submenu listing columns except current)
- Set priority → (submenu with 5 options)
- Duplicate (new card with same fields, `(copy)` suffix, new ID, inserted below original)
- Separator
- Delete (confirmation dialog)
**New store action**: `duplicateCard(cardId): string` — clones card, inserts after original in same column.
**Files**: `CardThumbnail.tsx`, `board-store.ts`
### #10 — WIP Limits
**Column header display**: Shows `3/5` when wipLimit set. Background tint:
- Under limit: normal
- At limit: amber tint `oklch(75% 0.08 70 / 15%)`
- Over limit: red tint `oklch(70% 0.08 25 / 15%)`
**Setting UI**: New "Set WIP Limit" item in ColumnHeader dropdown menu. Preset choices: None / 3 / 5 / 7 / 10 / Custom.
**New store action**: `setColumnWipLimit(columnId: string, limit: number | null)`
**Files**: `ColumnHeader.tsx`, `KanbanColumn.tsx`, `board-store.ts`
### #3 — Column Collapse/Expand
When `collapsed`, render a 40px-wide strip instead of full column:
- Vertical text via `writing-mode: vertical-rl; rotate: 180deg`
- Card count badge
- Click to expand
Animate width from full to 40px using existing `animate={{ width }}` on outer `motion.div` with `springs.bouncy`.
**New store action**: `toggleColumnCollapse(columnId: string)`
**Collapse button**: Added to ColumnHeader dropdown menu + a small chevron icon on the collapsed strip.
**Files**: `KanbanColumn.tsx`, `ColumnHeader.tsx`, `board-store.ts`
### #11 — Checklist Item Reordering
Wrap checklist `<ul>` in `ChecklistSection` with `DndContext` + `SortableContext` (vertical strategy). Each `ChecklistRow` becomes sortable.
**Drag handle**: `GripVertical` icon on left of each item, visible on hover.
**Drop indicator**: Horizontal glow line (same as card drag — vertical list so horizontal line is correct).
**New store action**: `reorderChecklistItems(cardId: string, fromIndex: number, toIndex: number)`
**Files**: `ChecklistSection.tsx`, `board-store.ts`
---
## Phase 3: Navigation & Power User Features
Features that make power users fall in love.
### #1 — Card Filtering & Quick Search
**Filter bar**: Slides down below TopBar. Triggered by filter icon in TopBar or `/` keyboard shortcut.
**Filter controls** (horizontal row):
- Text input (debounced 200ms, title search)
- Label multi-select dropdown (ANY match)
- Due date dropdown: All / Overdue / Due this week / Due today / No date
- Priority dropdown: All / Urgent / High / Medium / Low
- Clear all button
**State**: Local state in `BoardView` (not persisted — filters are ephemeral).
**Rendering**: `KanbanColumn` receives filtered card IDs. Non-matching cards fade out. Column counts show `3 of 7` when filtering.
**Files**: New `FilterBar.tsx` component, `BoardView.tsx`, `KanbanColumn.tsx`, `TopBar.tsx` (filter button)
### #7 — Keyboard Card Navigation
**State**: `focusedCardId` in `BoardView` local state.
**Key bindings** (when no input/textarea focused):
| Key | Action |
|-----|--------|
| `J` / `ArrowDown` | Focus next card in column |
| `K` / `ArrowUp` | Focus previous card in column |
| `H` / `ArrowLeft` | Focus same-index card in previous column |
| `L` / `ArrowRight` | Focus same-index card in next column |
| `Enter` | Open focused card detail |
| `Escape` | Clear focus / close modal |
**Visual**: Focused card gets `ring-2 ring-pylon-accent ring-offset-2`. Column auto-scrolls via `scrollIntoView({ block: "nearest" })`.
**Implementation**: `useKeyboardNavigation` hook. Passes `isFocused` through `KanbanColumn` to `CardThumbnail`.
**Files**: New `useKeyboardNavigation.ts` hook, `BoardView.tsx`, `KanbanColumn.tsx`, `CardThumbnail.tsx`
### #6 — Desktop Notifications for Due Dates
**Plugin**: Add `tauri-plugin-notification` to `Cargo.toml` and capabilities.
**Trigger**: On `useAppStore.init()`, after loading boards, scan all cards:
- Cards due today → "You have X cards due today"
- Cards overdue → "You have X overdue cards"
Batched (one notification per category). Store `lastNotificationCheck` in settings to skip if checked within last hour.
**Files**: `src-tauri/Cargo.toml`, `src-tauri/capabilities/default.json`, `src/stores/app-store.ts`, `src/types/settings.ts` (add `lastNotificationCheck`), `src/lib/schemas.ts`
### #13 — Card Comments / Activity Log
**UI in CardDetailModal**: New section in right column below description.
- Scrollable comment list (newest first)
- Each: text, relative timestamp, delete button (hover)
- Add input: textarea + "Add" button. Enter submits, Shift+Enter newline.
**Store actions**: `addComment(cardId, text)`, `deleteComment(cardId, commentId)`. Comments get ULID IDs and `createdAt`.
**Files**: New `CommentsSection.tsx`, `CardDetailModal.tsx`, `board-store.ts`
---
## Phase 4: System Features & Infrastructure
Deeper features touching storage and templates.
### #14 — Board Templates & Saved Structures
**Template type**:
```typescript
interface BoardTemplate {
id: string;
name: string;
color: string;
columns: { title: string; width: ColumnWidth; color: string | null; wipLimit: number | null }[];
labels: Label[];
settings: BoardSettings;
}
```
**Saving**: Context menu item on `BoardCard` — "Save as Template". Prompts for name. Strips cards/timestamps. Writes to `data/templates/{id}.json`.
**Creating**: `NewBoardDialog` shows built-in templates (Blank, Kanban, Sprint) + user templates below a separator. Delete button (X) on user templates.
**Storage functions**: `listTemplates()`, `saveTemplate()`, `deleteTemplate()` in `storage.ts`. `board-factory.ts` gets `createBoardFromTemplate()`.
**Files**: `storage.ts`, `board-factory.ts`, `NewBoardDialog.tsx`, `BoardCard.tsx`, new `src/types/template.ts`
### #15 — Auto-Backup & Version History
**Storage**: `data/backups/{boardId}/` directory. Timestamped files: `{boardId}-{ISO timestamp}.json`.
**Save flow** in `board-store.ts`:
1. Read current file as previous version
2. Write new board to `{boardId}.json`
3. Write previous version to `data/backups/{boardId}/{boardId}-{timestamp}.json`
4. Prune backups beyond 10
**UI — Version History dialog**: Accessible from board settings dropdown menu ("Version History"). Shows:
- List of backups sorted newest-first
- Each entry: relative timestamp, card count, column count
- "Restore" button with confirmation dialog
- Current board auto-backed-up before restore (restore is reversible)
**Storage functions**: `listBackups(boardId)`, `restoreBackup(boardId, filename)`, `pruneBackups(boardId, keep)`.
**Files**: `storage.ts`, `board-store.ts`, new `VersionHistoryDialog.tsx`, `TopBar.tsx` (menu item)
---
## Dependency Graph
```
Phase 0 (data model)
└── Phase 1 (quick wins) — no deps on Phase 0 except #8
└── Phase 2 (card interactions) — needs priority + collapsed + wipLimit from Phase 0
└── Phase 3 (power user) — needs priority for filtering, context menu patterns from Phase 2
└── Phase 4 (infrastructure) — needs wipLimit in templates from Phase 0+2
```
Phase 1 can actually run in parallel with Phase 0 since its features don't touch the new fields. Phases 2-4 are strictly sequential.

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
<title>OpenPylon</title> <title>OpenPylon</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Epilogue:ital,wght@0,100..900;1,100..900&family=Instrument+Serif&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>

57
package-lock.json generated
View File

@@ -11,9 +11,11 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/typography": "^0.5.19",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -22,6 +24,8 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-react": "^0.5.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -3678,6 +3682,31 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tailwindcss/vite": { "node_modules/@tailwindcss/vite": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
@@ -3938,6 +3967,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
@@ -4865,7 +4903,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"cssesc": "bin/cssesc" "cssesc": "bin/cssesc"
@@ -8089,6 +8126,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/overlayscrollbars": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.14.0.tgz",
"integrity": "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig==",
"license": "MIT"
},
"node_modules/overlayscrollbars-react": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz",
"integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==",
"license": "MIT",
"peerDependencies": {
"overlayscrollbars": "^2.0.0",
"react": ">=16.8.0"
}
},
"node_modules/package-manager-detector": { "node_modules/package-manager-detector": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@@ -9322,7 +9375,6 @@
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {
@@ -9762,7 +9814,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/validate-npm-package-name": { "node_modules/validate-npm-package-name": {

View File

@@ -1,7 +1,7 @@
{ {
"name": "openpylon", "name": "openpylon",
"private": true, "private": true,
"version": "0.1.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -13,9 +13,11 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/typography": "^0.5.19",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -24,6 +26,8 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-react": "^0.5.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",

5543
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "openpylon" name = "openpylon"
version = "0.1.0" version = "1.0.0"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -23,6 +23,7 @@ tauri-plugin-opener = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -5,40 +5,24 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"shell:default", "shell:default",
{ "fs:default",
"identifier": "fs:default", "fs:read-all",
"allow": [{ "path": "$APPDATA/openpylon/**" }] "fs:write-all",
}, "core:window:allow-set-size",
{ "core:window:allow-set-position",
"identifier": "fs:allow-exists", "core:window:allow-outer-size",
"allow": [{ "path": "$APPDATA/openpylon/**" }] "core:window:allow-outer-position",
}, "notification:default"
{
"identifier": "fs:allow-read",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-write",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-mkdir",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-remove",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-copy-file",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-read-dir",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
}
] ]
} }

View File

@@ -1,3 +1,15 @@
use tauri_plugin_fs::FsExt;
#[tauri::command]
fn get_portable_data_dir() -> Result<String, String> {
let exe_path = std::env::current_exe().map_err(|e| e.to_string())?;
let exe_dir = exe_path
.parent()
.ok_or_else(|| "Failed to get exe directory".to_string())?;
let data_dir = exe_dir.join("data");
Ok(data_dir.to_string_lossy().to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -5,6 +17,28 @@ pub fn run() {
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
// Get portable data directory next to the exe
let exe_path =
std::env::current_exe().expect("Failed to get exe path");
let exe_dir = exe_path
.parent()
.expect("Failed to get exe directory");
let data_dir = exe_dir.join("data");
// Ensure data directory exists
std::fs::create_dir_all(&data_dir)
.expect("Failed to create portable data directory");
// Allow FS plugin access to the portable data directory
app.fs_scope()
.allow_directory(&data_dir, true)
.expect("Failed to allow data directory in FS scope");
Ok(())
})
.invoke_handler(tauri::generate_handler![get_portable_data_dir])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
temptauri_lib::run() openpylon_lib::run()
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "openpylon", "productName": "openpylon",
"version": "0.1.0", "version": "1.0.0",
"identifier": "com.openpylon.app", "identifier": "com.openpylon.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -14,7 +14,10 @@
{ {
"title": "OpenPylon", "title": "OpenPylon",
"width": 1200, "width": 1200,
"height": 800 "height": 800,
"minWidth": 800,
"minHeight": 600,
"decorations": false
} }
], ],
"security": { "security": {

View File

@@ -1,10 +1,17 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { getCurrentWindow, LogicalSize, LogicalPosition } from "@tauri-apps/api/window";
import { AnimatePresence, motion } from "framer-motion";
import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store";
import { saveSettings } from "@/lib/storage";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { BoardList } from "@/components/boards/BoardList"; import { BoardList } from "@/components/boards/BoardList";
import { BoardView } from "@/components/board/BoardView"; import { BoardView } from "@/components/board/BoardView";
import { CommandPalette } from "@/components/command-palette/CommandPalette"; import { CommandPalette } from "@/components/command-palette/CommandPalette";
import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { ToastContainer } from "@/components/toast/ToastContainer";
import { ShortcutHelpModal } from "@/components/shortcuts/ShortcutHelpModal";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
export default function App() { export default function App() {
@@ -13,11 +20,75 @@ export default function App() {
const view = useAppStore((s) => s.view); const view = useAppStore((s) => s.view);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
useEffect(() => { useEffect(() => {
init(); init().then(() => {
// Restore window state after settings are loaded
const { settings } = useAppStore.getState();
const ws = settings.windowState;
if (ws) {
const appWindow = getCurrentWindow();
if (ws.maximized) {
appWindow.maximize();
} else {
appWindow.setSize(new LogicalSize(ws.width, ws.height));
appWindow.setPosition(new LogicalPosition(ws.x, ws.y));
}
}
});
}, [init]); }, [init]);
// Flush board saves before the app window closes
useEffect(() => {
function handleBeforeUnload() {
useBoardStore.getState().closeBoard();
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, []);
// Save window state on resize/move (debounced) so it persists without blocking close
useEffect(() => {
const appWindow = getCurrentWindow();
let timeout: ReturnType<typeof setTimeout> | null = null;
async function saveWindowState() {
const [size, position, maximized] = await Promise.all([
appWindow.outerSize(),
appWindow.outerPosition(),
appWindow.isMaximized(),
]);
const settings = useAppStore.getState().settings;
await saveSettings({
...settings,
windowState: {
x: position.x,
y: position.y,
width: size.width,
height: size.height,
maximized,
},
});
}
function debouncedSave() {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(saveWindowState, 500);
}
const unlistenResize = appWindow.onResized(debouncedSave);
const unlistenMove = appWindow.onMoved(debouncedSave);
return () => {
if (timeout) clearTimeout(timeout);
unlistenResize.then((fn) => fn());
unlistenMove.then((fn) => fn());
};
}, []);
// Listen for custom event to open settings from TopBar or command palette // Listen for custom event to open settings from TopBar or command palette
useEffect(() => { useEffect(() => {
function handleOpenSettings() { function handleOpenSettings() {
@@ -29,6 +100,16 @@ export default function App() {
}; };
}, []); }, []);
useEffect(() => {
function handleOpenShortcutHelp() {
setShortcutHelpOpen(true);
}
document.addEventListener("open-shortcut-help", handleOpenShortcutHelp);
return () => {
document.removeEventListener("open-shortcut-help", handleOpenShortcutHelp);
};
}, []);
const handleOpenSettings = useCallback(() => { const handleOpenSettings = useCallback(() => {
setSettingsOpen(true); setSettingsOpen(true);
}, []); }, []);
@@ -48,10 +129,38 @@ export default function App() {
return ( return (
<> <>
<AppShell> <AppShell>
{view.type === "board-list" ? <BoardList /> : <BoardView />} <AnimatePresence mode="wait">
{view.type === "board-list" ? (
<motion.div
key="board-list"
className="h-full"
variants={fadeSlideRight}
initial="hidden"
animate="visible"
exit="exit"
transition={springs.gentle}
>
<BoardList />
</motion.div>
) : (
<motion.div
key={`board-${view.boardId}`}
className="h-full"
variants={fadeSlideLeft}
initial="hidden"
animate="visible"
exit="exit"
transition={springs.gentle}
>
<BoardView />
</motion.div>
)}
</AnimatePresence>
</AppShell> </AppShell>
<CommandPalette onOpenSettings={handleOpenSettings} /> <CommandPalette onOpenSettings={handleOpenSettings} />
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} /> <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<ToastContainer />
<ShortcutHelpModal open={shortcutHelpOpen} onOpenChange={setShortcutHelpOpen} />
</> </>
); );
} }

View File

@@ -1,5 +1,8 @@
import { useState, useRef, useEffect, useCallback } from "react"; import React, { useState, useRef, useEffect, useCallback } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import { staggerContainer } from "@/lib/motion";
import { import {
DndContext, DndContext,
DragOverlay, DragOverlay,
@@ -24,12 +27,38 @@ import {
ColumnOverlay, ColumnOverlay,
} from "@/components/board/DragOverlayContent"; } from "@/components/board/DragOverlayContent";
import { CardDetailModal } from "@/components/card-detail/CardDetailModal"; import { CardDetailModal } from "@/components/card-detail/CardDetailModal";
import { FilterBar, type FilterState, EMPTY_FILTER, isFilterActive } from "@/components/board/FilterBar";
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
import type { Board } from "@/types/board"; import type { Board } from "@/types/board";
function findColumnByCardId(board: Board, cardId: string) { function findColumnByCardId(board: Board, cardId: string) {
return board.columns.find((col) => col.cardIds.includes(cardId)); return board.columns.find((col) => col.cardIds.includes(cardId));
} }
function getBoardBackground(board: Board): React.CSSProperties {
const bg = board.settings.background;
if (bg === "dots") {
return {
backgroundImage: `radial-gradient(circle, currentColor 1px, transparent 1px)`,
backgroundSize: "20px 20px",
color: "oklch(50% 0 0 / 5%)",
};
}
if (bg === "grid") {
return {
backgroundImage: `linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)`,
backgroundSize: "24px 24px",
color: "oklch(50% 0 0 / 5%)",
};
}
if (bg === "gradient") {
return {
background: `linear-gradient(135deg, ${board.color}08, ${board.color}03, transparent)`,
};
}
return {};
}
export function BoardView() { export function BoardView() {
const board = useBoardStore((s) => s.board); const board = useBoardStore((s) => s.board);
const addColumn = useBoardStore((s) => s.addColumn); const addColumn = useBoardStore((s) => s.addColumn);
@@ -37,9 +66,77 @@ export function BoardView() {
const moveColumn = useBoardStore((s) => s.moveColumn); const moveColumn = useBoardStore((s) => s.moveColumn);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null); const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, handleCardClick);
const [addingColumn, setAddingColumn] = useState(false); const [addingColumn, setAddingColumn] = useState(false);
const [newColumnTitle, setNewColumnTitle] = useState(""); const [newColumnTitle, setNewColumnTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
// Track columns that existed on initial render (for stagger vs instant appearance)
const initialColumnIds = useRef<Set<string> | null>(null);
if (initialColumnIds.current === null && board) {
initialColumnIds.current = new Set(board.columns.map((c) => c.id));
}
function handleCardClick(cardId: string) {
setSelectedCardId(cardId);
setFocusedCardId(null);
}
function filterCards(cardIds: string[]): string[] {
if (!isFilterActive(filters) || !board) return cardIds;
return cardIds.filter((id) => {
const card = board.cards[id];
if (!card) return false;
if (filters.text && !card.title.toLowerCase().includes(filters.text.toLowerCase())) return false;
if (filters.labels.length > 0 && !filters.labels.some((l) => card.labels.includes(l))) return false;
if (filters.priority !== "all" && card.priority !== filters.priority) return false;
if (filters.dueDate !== "all") {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (filters.dueDate === "none" && card.dueDate != null) return false;
if (filters.dueDate === "overdue" && (!card.dueDate || new Date(card.dueDate) >= today)) return false;
if (filters.dueDate === "today") {
if (!card.dueDate) return false;
const d = new Date(card.dueDate);
if (d.toDateString() !== today.toDateString()) return false;
}
if (filters.dueDate === "week") {
if (!card.dueDate) return false;
const d = new Date(card.dueDate);
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
if (d < today || d > weekEnd) return false;
}
}
return true;
});
}
// Keyboard shortcut: "/" to open filter bar
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.preventDefault();
setShowFilterBar(true);
}
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, []);
// Listen for toggle-filter-bar custom event from TopBar
useEffect(() => {
function handleToggleFilter() {
setShowFilterBar((prev) => !prev);
}
document.addEventListener("toggle-filter-bar", handleToggleFilter);
return () => document.removeEventListener("toggle-filter-bar", handleToggleFilter);
}, []);
// Listen for custom event to open card detail from command palette // Listen for custom event to open card detail from command palette
useEffect(() => { useEffect(() => {
@@ -79,6 +176,15 @@ export function BoardView() {
addColumn(trimmed); addColumn(trimmed);
setNewColumnTitle(""); setNewColumnTitle("");
inputRef.current?.focus(); inputRef.current?.focus();
// Force OverlayScrollbars to detect the new content and scroll to show it
setTimeout(() => {
const instance = osRef.current?.osInstance();
if (instance) {
instance.update(true);
const viewport = instance.elements().viewport;
viewport.scrollTo({ left: viewport.scrollWidth, behavior: "smooth" });
}
}, 50);
} }
} }
@@ -93,82 +199,96 @@ export function BoardView() {
// --- Drag handlers --- // --- Drag handlers ---
// Debounce cross-column moves to prevent oscillation crashes
const lastCrossColumnMoveRef = useRef<number>(0);
const clearDragState = useCallback(() => {
setActiveId(null);
setActiveType(null);
lastCrossColumnMoveRef.current = 0;
}, []);
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event; const { active } = event;
const type = active.data.current?.type as "card" | "column" | undefined; const type = active.data.current?.type as "card" | "column" | undefined;
setActiveId(active.id as string); setActiveId(active.id as string);
setActiveType(type ?? null); setActiveType(type ?? null);
lastCrossColumnMoveRef.current = 0;
}, []); }, []);
const handleDragOver = useCallback( const handleDragOver = useCallback(
(event: DragOverEvent) => { (event: DragOverEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || !board) return; if (!over) return;
// Always read fresh state to avoid stale-closure bugs
const currentBoard = useBoardStore.getState().board;
if (!currentBoard) return;
const activeType = active.data.current?.type; const activeType = active.data.current?.type;
if (activeType !== "card") return; // Only handle card cross-column moves here if (activeType !== "card") return;
const activeCardId = active.id as string; const activeCardId = active.id as string;
const overId = over.id as string; const overId = over.id as string;
if (overId === activeCardId) return;
// Determine the source column const activeColumn = findColumnByCardId(currentBoard, activeCardId);
const activeColumn = findColumnByCardId(board, activeCardId);
if (!activeColumn) return; if (!activeColumn) return;
// Determine the target column
let overColumn: ReturnType<typeof findColumnByCardId>; let overColumn: ReturnType<typeof findColumnByCardId>;
let overIndex: number; let overIndex: number;
// Check if we're hovering over a card
const overType = over.data.current?.type; const overType = over.data.current?.type;
if (overType === "card") { if (overType === "card") {
overColumn = findColumnByCardId(board, overId); overColumn = findColumnByCardId(currentBoard, overId);
if (!overColumn) return; if (!overColumn) return;
overIndex = overColumn.cardIds.indexOf(overId); overIndex = overColumn.cardIds.indexOf(overId);
} else if (overType === "column") { } else if (overType === "column") {
// Hovering over the droppable area of a column
const columnId = over.data.current?.columnId as string | undefined; const columnId = over.data.current?.columnId as string | undefined;
if (columnId) { if (columnId) {
overColumn = board.columns.find((c) => c.id === columnId); overColumn = currentBoard.columns.find((c) => c.id === columnId);
} else { } else {
overColumn = board.columns.find((c) => c.id === overId); overColumn = currentBoard.columns.find((c) => c.id === overId);
} }
if (!overColumn) return; if (!overColumn) return;
overIndex = overColumn.cardIds.length; // Append to end overIndex = overColumn.cardIds.length;
} else { } else {
return; return;
} }
// Only move if we're going to a different column or different position // Only move cross-column (within-column handled by sortable transforms + dragEnd)
if (activeColumn.id === overColumn.id) return; if (activeColumn.id === overColumn.id) return;
// Debounce: prevent rapid oscillation between columns
const now = Date.now();
if (now - lastCrossColumnMoveRef.current < 100) return;
lastCrossColumnMoveRef.current = now;
moveCard(activeCardId, activeColumn.id, overColumn.id, overIndex); moveCard(activeCardId, activeColumn.id, overColumn.id, overIndex);
}, },
[board, moveCard] [moveCard]
); );
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
try {
const { active, over } = event; const { active, over } = event;
if (!over || !board) { // Always read fresh state
setActiveId(null); const currentBoard = useBoardStore.getState().board;
setActiveType(null); if (!over || !currentBoard) return;
return;
}
const type = active.data.current?.type; const type = active.data.current?.type;
if (type === "column") { if (type === "column") {
// Column reordering
const activeColumnId = active.id as string; const activeColumnId = active.id as string;
const overColumnId = over.id as string; const overColumnId = over.id as string;
if (activeColumnId !== overColumnId) { if (activeColumnId !== overColumnId) {
const fromIndex = board.columns.findIndex( const fromIndex = currentBoard.columns.findIndex(
(c) => c.id === activeColumnId (c) => c.id === activeColumnId
); );
const toIndex = board.columns.findIndex( const toIndex = currentBoard.columns.findIndex(
(c) => c.id === overColumnId (c) => c.id === overColumnId
); );
if (fromIndex !== -1 && toIndex !== -1) { if (fromIndex !== -1 && toIndex !== -1) {
@@ -176,29 +296,19 @@ export function BoardView() {
} }
} }
} else if (type === "card") { } else if (type === "card") {
// Card reordering within same column (cross-column already handled in onDragOver)
const activeCardId = active.id as string; const activeCardId = active.id as string;
const overId = over.id as string; const overId = over.id as string;
const activeColumn = findColumnByCardId(board, activeCardId); const activeColumn = findColumnByCardId(currentBoard, activeCardId);
if (!activeColumn) { if (!activeColumn) return;
setActiveId(null);
setActiveType(null);
return;
}
const overType = over.data.current?.type; const overType = over.data.current?.type;
if (overType === "card") { if (overType === "card") {
const overColumn = findColumnByCardId(board, overId); const overColumn = findColumnByCardId(currentBoard, overId);
if (!overColumn) { if (!overColumn) return;
setActiveId(null);
setActiveType(null);
return;
}
if (activeColumn.id === overColumn.id) { if (activeColumn.id === overColumn.id) {
// Within same column, reorder
const oldIndex = activeColumn.cardIds.indexOf(activeCardId); const oldIndex = activeColumn.cardIds.indexOf(activeCardId);
const newIndex = activeColumn.cardIds.indexOf(overId); const newIndex = activeColumn.cardIds.indexOf(overId);
if (oldIndex !== newIndex) { if (oldIndex !== newIndex) {
@@ -206,10 +316,9 @@ export function BoardView() {
} }
} }
} else if (overType === "column") { } else if (overType === "column") {
// Dropped on an empty column droppable
const columnId = over.data.current?.columnId as string | undefined; const columnId = over.data.current?.columnId as string | undefined;
const targetColumnId = columnId ?? (over.id as string); const targetColumnId = columnId ?? (over.id as string);
const targetColumn = board.columns.find( const targetColumn = currentBoard.columns.find(
(c) => c.id === targetColumnId (c) => c.id === targetColumnId
); );
@@ -223,11 +332,41 @@ export function BoardView() {
} }
} }
} }
} finally {
setActiveId(null); clearDragState();
setActiveType(null); }
}, },
[board, moveCard, moveColumn] [moveCard, moveColumn, clearDragState]
);
const [announcement, setAnnouncement] = useState("");
const handleDragEndWithAnnouncement = useCallback(
(event: DragEndEvent) => {
// Read board BEFORE handleDragEnd potentially modifies it
const currentBoard = useBoardStore.getState().board;
handleDragEnd(event);
const { active, over } = event;
if (over && currentBoard) {
const type = active.data.current?.type;
if (type === "card") {
const card = currentBoard.cards[active.id as string];
const targetCol = over.data.current?.type === "column"
? currentBoard.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id))
: findColumnByCardId(currentBoard, over.id as string);
if (card && targetCol) {
setAnnouncement(`Moved card "${card.title}" to ${targetCol.title}`);
}
} else if (type === "column") {
const col = currentBoard.columns.find((c) => c.id === (active.id as string));
if (col) {
setAnnouncement(`Reordered column "${col.title}"`);
}
}
}
},
[handleDragEnd]
); );
if (!board) { if (!board) {
@@ -250,25 +389,61 @@ export function BoardView() {
return ( return (
<> <>
{/* Visually hidden live region for drag-and-drop announcements */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
<AnimatePresence>
{showFilterBar && board && (
<FilterBar
filters={filters}
onChange={setFilters}
onClose={() => { setShowFilterBar(false); setFilters(EMPTY_FILTER); }}
boardLabels={board.labels}
/>
)}
</AnimatePresence>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={closestCorners}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEndWithAnnouncement}
onDragCancel={clearDragState}
> >
<SortableContext <SortableContext
items={columnIds} items={columnIds}
strategy={horizontalListSortingStrategy} strategy={horizontalListSortingStrategy}
> >
<div className="flex h-full gap-6 overflow-x-auto p-6"> <OverlayScrollbarsComponent
ref={osRef}
className="h-full"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { y: "hidden" } }}
defer
>
<motion.div
className="flex h-full"
style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))`, ...getBoardBackground(board) }}
variants={staggerContainer(0.06)}
initial="hidden"
animate="visible"
>
<AnimatePresence>
{board.columns.map((column) => ( {board.columns.map((column) => (
<KanbanColumn <KanbanColumn
key={column.id} key={column.id}
column={column} column={column}
onCardClick={setSelectedCardId} filteredCardIds={isFilterActive(filters) ? filterCards(column.cardIds) : undefined}
focusedCardId={focusedCardId}
onCardClick={handleCardClick}
isNew={!initialColumnIds.current?.has(column.id)}
/> />
))} ))}
</AnimatePresence>
{/* Add column button / inline input */} {/* Add column button / inline input */}
<div className="shrink-0"> <div className="shrink-0">
@@ -316,22 +491,25 @@ export function BoardView() {
</Button> </Button>
)} )}
</div> </div>
</div> </motion.div>
</OverlayScrollbarsComponent>
</SortableContext> </SortableContext>
{/* Drag overlay - renders a styled copy of the dragged item */} {/* Drag overlay - renders a styled copy of the dragged item */}
<DragOverlay> <DragOverlay dropAnimation={null}>
<AnimatePresence>
{activeCard ? ( {activeCard ? (
<CardOverlay card={activeCard} boardLabels={board.labels} /> <CardOverlay key="card-overlay" card={activeCard} boardLabels={board.labels} />
) : activeColumn ? ( ) : activeColumn ? (
<ColumnOverlay column={activeColumn} /> <ColumnOverlay key="column-overlay" column={activeColumn} />
) : null} ) : null}
</AnimatePresence>
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
<CardDetailModal <CardDetailModal
cardId={selectedCardId} cardId={selectedCardId}
onClose={() => setSelectedCardId(null)} onClose={() => { setSelectedCardId(null); }}
/> />
</> </>
); );

View File

@@ -1,19 +1,74 @@
import { format, isPast, isToday } from "date-fns"; import { useState, useRef, useEffect } from "react";
import { motion, useReducedMotion } from "framer-motion"; import { createPortal } from "react-dom";
import { format } from "date-fns";
import { motion, useReducedMotion, AnimatePresence } from "framer-motion";
import { fadeSlideUp, springs } from "@/lib/motion";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import type { Card, Label } from "@/types/board"; import type { Card, Label, Priority } from "@/types/board";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { LabelDots } from "@/components/board/LabelDots"; import { LabelDots } from "@/components/board/LabelDots";
import { ChecklistBar } from "@/components/board/ChecklistBar"; import { ChecklistBar } from "@/components/board/ChecklistBar";
import { Paperclip, AlignLeft } from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useBoardStore } from "@/stores/board-store";
/* ---------- Due date status ---------- */
function getDueDateStatus(dueDate: string | null): { color: string; bgColor: string; label: string } | null {
if (!dueDate) return null;
const date = new Date(dueDate);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dueDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.ceil((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { color: "text-pylon-danger", bgColor: "bg-pylon-danger/10", label: "Overdue" };
}
if (diffDays <= 2) {
return { color: "text-[oklch(65%_0.15_70)]", bgColor: "bg-[oklch(65%_0.15_70/10%)]", label: "Due soon" };
}
return { color: "text-[oklch(55%_0.12_145)]", bgColor: "bg-[oklch(55%_0.12_145/10%)]", label: "Upcoming" };
}
/* ---------- Card aging ---------- */
function getAgingOpacity(updatedAt: string): number {
const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);
if (days <= 7) return 1.0;
if (days <= 14) return 0.85;
if (days <= 30) return 0.7;
return 0.55;
}
/* ---------- Priority colors ---------- */
const PRIORITY_COLORS: Record<string, string> = {
low: "oklch(60% 0.15 240)",
medium: "oklch(70% 0.15 85)",
high: "oklch(60% 0.15 55)",
urgent: "oklch(55% 0.15 25)",
};
interface CardThumbnailProps { interface CardThumbnailProps {
card: Card; card: Card;
boardLabels: Label[]; boardLabels: Label[];
columnId: string; columnId: string;
onCardClick?: (cardId: string) => void; onCardClick?: (cardId: string) => void;
isFocused?: boolean;
} }
export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: CardThumbnailProps) { export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocused }: CardThumbnailProps) {
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const { const {
@@ -28,32 +83,81 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
data: { type: "card", columnId }, data: { type: "card", columnId },
}); });
const style = { const cardRef = useRef<HTMLButtonElement>(null);
transform: CSS.Transform.toString(transform),
transition, useEffect(() => {
opacity: isDragging ? 0.3 : undefined, if (isFocused && cardRef.current) {
}; cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [isFocused]);
const hasDueDate = card.dueDate != null; const hasDueDate = card.dueDate != null;
const dueDate = hasDueDate ? new Date(card.dueDate!) : null; const dueDateStatus = getDueDateStatus(card.dueDate);
const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate);
function handleClick() { function handleClick() {
onCardClick?.(card.id); onCardClick?.(card.id);
} }
// Drop indicator line when this card is being dragged
if (isDragging) {
return ( return (
<motion.button <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={{
onClick={handleClick} transform: CSS.Transform.toString(transform),
className="w-full rounded-lg bg-pylon-surface p-3 shadow-sm text-left transition-all duration-200 hover:-translate-y-px hover:shadow-md" transition,
initial={prefersReducedMotion ? false : { opacity: 0, y: -8 }} }}
animate={{ opacity: 1, y: 0 }} className="py-1"
transition={{ type: "spring", stiffness: 300, damping: 25 }}
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
<div className="h-[3px] rounded-full bg-pylon-accent shadow-[0_0_6px_var(--pylon-accent),0_0_14px_var(--pylon-accent)]" />
</div>
);
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<motion.button
ref={(node) => {
setNodeRef(node);
(cardRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
}}
style={{
transform: CSS.Transform.toString(transform),
transition,
padding: `calc(0.75rem * var(--density-factor))`,
opacity: getAgingOpacity(card.updatedAt),
}}
onClick={handleClick}
className={`w-full rounded-lg bg-pylon-surface shadow-sm text-left ${
isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : ""
}`}
layoutId={`card-${card.id}`}
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }}
whileTap={{ scale: 0.98 }}
transition={springs.bouncy}
layout
{...attributes}
{...listeners}
role="article"
aria-label={card.title}
>
{/* Cover color bar */}
{card.coverColor && (
<div
className="mb-2 h-1 rounded-t-lg"
style={{
backgroundColor: `oklch(55% 0.12 ${card.coverColor})`,
margin: `calc(-0.75rem * var(--density-factor)) calc(-0.75rem * var(--density-factor)) 0.5rem`,
}}
/>
)}
{/* Label dots */} {/* Label dots */}
{card.labels.length > 0 && ( {card.labels.length > 0 && (
<div className="mb-2"> <div className="mb-2">
@@ -64,25 +168,222 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
{/* Card title */} {/* Card title */}
<p className="text-sm font-medium text-pylon-text">{card.title}</p> <p className="text-sm font-medium text-pylon-text">{card.title}</p>
{/* Footer row: due date + checklist */} {/* Footer row: priority + due date + checklist + icons */}
{(hasDueDate || card.checklist.length > 0) && ( {(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
<div className="mt-2 flex items-center gap-3"> <div className="mt-2 flex items-center gap-3">
{dueDate && ( {card.priority !== "none" && (
<span <span
className={`font-mono text-xs ${ className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
overdue style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
? "rounded bg-pylon-danger/10 px-1 py-0.5 text-pylon-danger" title={`Priority: ${card.priority}`}
: "text-pylon-text-secondary" />
}`} )}
{dueDateStatus && card.dueDate && (
<span
className={`font-mono text-xs rounded px-1 py-0.5 ${dueDateStatus.color} ${dueDateStatus.bgColor}`}
title={dueDateStatus.label}
> >
{format(dueDate, "MMM d")} {format(new Date(card.dueDate), "MMM d")}
</span> </span>
)} )}
{card.checklist.length > 0 && ( {card.checklist.length > 0 && (
<ChecklistBar checklist={card.checklist} /> <ChecklistBar checklist={card.checklist} />
)} )}
{card.description && (
<DescriptionPreview description={card.description} />
)}
{card.attachments.length > 0 && (
<span className="flex items-center gap-0.5 text-pylon-text-secondary">
<Paperclip className="size-3" />
<span className="font-mono text-xs">{card.attachments.length}</span>
</span>
)}
</div> </div>
)} )}
</motion.button> </motion.button>
</ContextMenuTrigger>
<CardContextMenuContent cardId={card.id} columnId={columnId} />
</ContextMenu>
);
}
/* ---------- Card context menu ---------- */
function CardContextMenuContent({ cardId, columnId }: { cardId: string; columnId: string }) {
const board = useBoardStore((s) => s.board);
const moveCard = useBoardStore((s) => s.moveCard);
const updateCard = useBoardStore((s) => s.updateCard);
const duplicateCard = useBoardStore((s) => s.duplicateCard);
const deleteCard = useBoardStore((s) => s.deleteCard);
if (!board) return null;
const otherColumns = board.columns.filter((c) => c.id !== columnId);
const priorities: { value: Priority; label: string }[] = [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
];
return (
<ContextMenuContent>
{otherColumns.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger>Move to</ContextMenuSubTrigger>
<ContextMenuSubContent>
{otherColumns.map((col) => (
<ContextMenuItem
key={col.id}
onClick={() => moveCard(cardId, columnId, col.id, col.cardIds.length)}
>
{col.title}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
<ContextMenuSub>
<ContextMenuSubTrigger>Set priority</ContextMenuSubTrigger>
<ContextMenuSubContent>
{priorities.map(({ value, label }) => (
<ContextMenuItem
key={value}
onClick={() => updateCard(cardId, { priority: value })}
>
{label}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem onClick={() => duplicateCard(cardId)}>
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => deleteCard(cardId)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
);
}
/* ---------- Description hover preview ---------- */
function DescriptionPreview({ description }: { description: string }) {
const [show, setShow] = useState(false);
const [pos, setPos] = useState<{ below: boolean; top?: number; bottom?: number; left: number; arrowLeft: number; maxHeight: number } | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const iconRef = useRef<HTMLSpanElement>(null);
function handleEnter() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const zoom = parseFloat(getComputedStyle(document.documentElement).fontSize) / 16;
const popupW = 224 * zoom; // w-56 = 14rem, scales with zoom
const gap = 8;
// Flip below if more room below than above
const titleBarH = 52 * zoom;
const spaceAbove = rect.top - gap - titleBarH;
const spaceBelow = window.innerHeight - rect.bottom - gap - 8;
const below = spaceBelow > spaceAbove;
// Max height for popup content (stay within viewport)
const paddingPx = 24 * zoom; // p-3 top + bottom
const maxAvailable = below
? window.innerHeight - (rect.bottom + gap) - 8
: rect.top - gap - titleBarH;
const maxHeight = Math.max(60, Math.min(maxAvailable - paddingPx, 300 * zoom));
// Center horizontally, clamp to viewport
let left = rect.left + rect.width / 2 - popupW / 2;
left = Math.max(8, Math.min(left, window.innerWidth - popupW - 8));
// Arrow offset relative to popup left edge
const arrowLeft = Math.max(12, Math.min(rect.left + rect.width / 2 - left, popupW - 12));
// Position: use top when below, bottom when above (avoids height estimation)
setPos(below
? { below: true, top: rect.bottom + gap, left, arrowLeft, maxHeight }
: { below: false, bottom: window.innerHeight - rect.top + gap, left, arrowLeft, maxHeight }
);
}
setShow(true);
}, 300);
}
function handleLeave() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setShow(false), 100);
}
function cancelHide() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}
// Strip markdown formatting for a clean preview
const plainText = description
.replace(/^#{1,6}\s+/gm, "")
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/`(.+?)`/g, "$1")
.replace(/\[(.+?)\]\(.+?\)/g, "$1")
.replace(/^[-*]\s+/gm, "- ")
.trim();
return (
<span
ref={iconRef}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
<AlignLeft className="size-3 text-pylon-text-secondary" />
{createPortal(
<AnimatePresence>
{show && pos && (
<motion.div
key="desc-preview"
initial={{ opacity: 0, y: pos.below ? -4 : 4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: pos.below ? -4 : 4, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="pointer-events-auto fixed z-[9999] w-56 rounded-lg border border-border bg-pylon-surface p-3 shadow-xl"
style={{
...(pos.below ? { top: pos.top } : { bottom: pos.bottom }),
left: pos.left,
}}
onMouseEnter={cancelHide}
onMouseLeave={() => setShow(false)}
onClick={(e) => e.stopPropagation()}
>
{/* Arrow */}
<div
className={`absolute size-2 rotate-45 border-border bg-pylon-surface ${
pos.below ? "-top-1 border-l border-t" : "-bottom-1 border-b border-r"
}`}
style={{ left: pos.arrowLeft }}
/>
<OverlayScrollbarsComponent
style={{ maxHeight: pos.maxHeight }}
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-pylon-text">
{plainText}
</p>
</OverlayScrollbarsComponent>
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</span>
); );
} }

View File

@@ -3,8 +3,11 @@ import { MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
@@ -17,9 +20,23 @@ import type { Column, ColumnWidth } from "@/types/board";
interface ColumnHeaderProps { interface ColumnHeaderProps {
column: Column; column: Column;
cardCount: number; cardCount: number;
filteredCount?: number;
} }
export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) { const COLOR_PRESETS = [
{ hue: "160", label: "Teal" },
{ hue: "240", label: "Blue" },
{ hue: "300", label: "Purple" },
{ hue: "350", label: "Pink" },
{ hue: "25", label: "Red" },
{ hue: "55", label: "Orange" },
{ hue: "85", label: "Yellow" },
{ hue: "130", label: "Lime" },
{ hue: "200", label: "Cyan" },
{ hue: "0", label: "Slate" },
];
export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(column.title); const [editValue, setEditValue] = useState(column.title);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -27,6 +44,9 @@ export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
const updateColumnTitle = useBoardStore((s) => s.updateColumnTitle); const updateColumnTitle = useBoardStore((s) => s.updateColumnTitle);
const deleteColumn = useBoardStore((s) => s.deleteColumn); const deleteColumn = useBoardStore((s) => s.deleteColumn);
const setColumnWidth = useBoardStore((s) => s.setColumnWidth); const setColumnWidth = useBoardStore((s) => s.setColumnWidth);
const setColumnColor = useBoardStore((s) => s.setColumnColor);
const setColumnWipLimit = useBoardStore((s) => s.setColumnWipLimit);
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
useEffect(() => { useEffect(() => {
if (editing && inputRef.current) { if (editing && inputRef.current) {
@@ -81,8 +101,14 @@ export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
{column.title} {column.title}
</span> </span>
)} )}
<span className="shrink-0 font-mono text-xs text-pylon-text-secondary"> <span className={`shrink-0 font-mono text-xs ${
{cardCount} column.wipLimit != null && cardCount > column.wipLimit
? "text-pylon-danger font-bold"
: column.wipLimit != null && cardCount === column.wipLimit
? "text-[oklch(65%_0.15_70)]"
: "text-pylon-text-secondary"
}`}>
{filteredCount != null ? `${filteredCount} of ` : ""}{cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
</span> </span>
</div> </div>
@@ -105,27 +131,59 @@ export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
> >
Rename Rename
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleColumnCollapse(column.id)}>
Collapse
</DropdownMenuItem>
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger>Width</DropdownMenuSubTrigger> <DropdownMenuSubTrigger>Width</DropdownMenuSubTrigger>
<DropdownMenuSubContent> <DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleWidthChange("narrow")}> <DropdownMenuRadioGroup value={column.width} onValueChange={(v) => handleWidthChange(v as ColumnWidth)}>
Narrow <DropdownMenuRadioItem value="narrow">Narrow</DropdownMenuRadioItem>
{column.width === "narrow" && ( <DropdownMenuRadioItem value="standard">Standard</DropdownMenuRadioItem>
<span className="ml-auto text-pylon-accent">*</span> <DropdownMenuRadioItem value="wide">Wide</DropdownMenuRadioItem>
)} </DropdownMenuRadioGroup>
</DropdownMenuItem> </DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleWidthChange("standard")}> </DropdownMenuSub>
Standard <DropdownMenuSub>
{column.width === "standard" && ( <DropdownMenuSubTrigger>Color</DropdownMenuSubTrigger>
<span className="ml-auto text-pylon-accent">*</span> <DropdownMenuSubContent>
)} <DropdownMenuCheckboxItem
</DropdownMenuItem> checked={column.color == null}
<DropdownMenuItem onClick={() => handleWidthChange("wide")}> onSelect={() => setColumnColor(column.id, null)}
Wide >
{column.width === "wide" && ( None
<span className="ml-auto text-pylon-accent">*</span> </DropdownMenuCheckboxItem>
)} <DropdownMenuSeparator />
</DropdownMenuItem> <div className="flex flex-wrap gap-1.5 px-2 py-1.5">
{COLOR_PRESETS.map(({ hue, label }) => (
<button
key={hue}
onClick={() => setColumnColor(column.id, hue)}
className="size-5 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: `oklch(55% 0.12 ${hue})`,
outline: column.color === hue ? "2px solid currentColor" : "none",
outlineOffset: "1px",
}}
title={label}
/>
))}
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>WIP Limit</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={column.wipLimit?.toString() ?? "none"}
onValueChange={(v) => setColumnWipLimit(column.id, v === "none" ? null : parseInt(v))}
>
<DropdownMenuRadioItem value="none">None</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="3">3</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="5">5</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="7">7</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="10">10</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@@ -1,7 +1,10 @@
import { useRef } from "react";
import { motion, useMotionValue, animate } from "framer-motion";
import type { Card, Column, Label } from "@/types/board"; import type { Card, Column, Label } from "@/types/board";
import { LabelDots } from "@/components/board/LabelDots"; import { LabelDots } from "@/components/board/LabelDots";
import { ChecklistBar } from "@/components/board/ChecklistBar"; import { ChecklistBar } from "@/components/board/ChecklistBar";
import { format, isPast, isToday } from "date-fns"; import { format, isPast, isToday } from "date-fns";
import { springs } from "@/lib/motion";
interface CardOverlayProps { interface CardOverlayProps {
card: Card; card: Card;
@@ -13,8 +16,32 @@ export function CardOverlay({ card, boardLabels }: CardOverlayProps) {
const dueDate = hasDueDate ? new Date(card.dueDate!) : null; const dueDate = hasDueDate ? new Date(card.dueDate!) : null;
const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate); const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate);
const rotate = useMotionValue(0);
const lastX = useRef(0);
return ( return (
<div className="w-[260px] rotate-2 scale-[1.03] rounded-lg bg-pylon-surface p-3 opacity-90 shadow-xl"> <motion.div
className="w-[260px] cursor-grabbing rounded-lg bg-pylon-surface p-3 shadow-xl"
style={{ rotate }}
initial={{ scale: 1, rotate: 0 }}
animate={{ scale: 1.05, boxShadow: "0 20px 40px oklch(0% 0 0 / 20%)" }}
exit={{ scale: 1, rotate: 0 }}
transition={springs.bouncy}
onPointerMove={(e) => {
const deltaX = e.clientX - lastX.current;
lastX.current = e.clientX;
const tilt = Math.max(-5, Math.min(5, deltaX * 0.3));
animate(rotate, tilt, { type: "spring", stiffness: 300, damping: 20 });
}}
>
{/* Cover color bar */}
{card.coverColor && (
<div
className="mb-2 -mx-3 -mt-3 h-1 rounded-t-lg"
style={{ backgroundColor: `oklch(55% 0.12 ${card.coverColor})` }}
/>
)}
{/* Label dots */} {/* Label dots */}
{card.labels.length > 0 && ( {card.labels.length > 0 && (
<div className="mb-2"> <div className="mb-2">
@@ -25,7 +52,7 @@ export function CardOverlay({ card, boardLabels }: CardOverlayProps) {
{/* Card title */} {/* Card title */}
<p className="text-sm font-medium text-pylon-text">{card.title}</p> <p className="text-sm font-medium text-pylon-text">{card.title}</p>
{/* Footer row: due date + checklist */} {/* Footer row */}
{(hasDueDate || card.checklist.length > 0) && ( {(hasDueDate || card.checklist.length > 0) && (
<div className="mt-2 flex items-center gap-3"> <div className="mt-2 flex items-center gap-3">
{dueDate && ( {dueDate && (
@@ -44,7 +71,7 @@ export function CardOverlay({ card, boardLabels }: CardOverlayProps) {
)} )}
</div> </div>
)} )}
</div> </motion.div>
); );
} }
@@ -54,7 +81,13 @@ interface ColumnOverlayProps {
export function ColumnOverlay({ column }: ColumnOverlayProps) { export function ColumnOverlay({ column }: ColumnOverlayProps) {
return ( return (
<div className="w-[280px] rotate-1 scale-[1.02] rounded-lg bg-pylon-column p-3 opacity-90 shadow-xl"> <motion.div
className="w-[280px] cursor-grabbing rounded-lg bg-pylon-column p-3 shadow-xl"
initial={{ scale: 1, rotate: 0 }}
animate={{ scale: 1.03, rotate: 1, boxShadow: "0 20px 40px oklch(0% 0 0 / 20%)" }}
exit={{ scale: 1, rotate: 0 }}
transition={springs.bouncy}
>
<div className="flex items-center gap-2 border-b border-border pb-2"> <div className="flex items-center gap-2 border-b border-border pb-2">
<span className="truncate font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"> <span className="truncate font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{column.title} {column.title}
@@ -65,10 +98,7 @@ export function ColumnOverlay({ column }: ColumnOverlayProps) {
</div> </div>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{column.cardIds.slice(0, 3).map((_, i) => ( {column.cardIds.slice(0, 3).map((_, i) => (
<div <div key={i} className="h-6 rounded bg-pylon-surface/50" />
key={i}
className="h-6 rounded bg-pylon-surface/50"
/>
))} ))}
{column.cardIds.length > 3 && ( {column.cardIds.length > 3 && (
<p className="text-center font-mono text-xs text-pylon-text-secondary"> <p className="text-center font-mono text-xs text-pylon-text-secondary">
@@ -76,6 +106,6 @@ export function ColumnOverlay({ column }: ColumnOverlayProps) {
</p> </p>
)} )}
</div> </div>
</div> </motion.div>
); );
} }

View File

@@ -0,0 +1,146 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { springs } from "@/lib/motion";
import { X, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Label, Priority } from "@/types/board";
export interface FilterState {
text: string;
labels: string[];
dueDate: "all" | "overdue" | "week" | "today" | "none";
priority: "all" | Priority;
}
export const EMPTY_FILTER: FilterState = {
text: "",
labels: [],
dueDate: "all",
priority: "all",
};
export function isFilterActive(f: FilterState): boolean {
return f.text !== "" || f.labels.length > 0 || f.dueDate !== "all" || f.priority !== "all";
}
interface FilterBarProps {
filters: FilterState;
onChange: (filters: FilterState) => void;
onClose: () => void;
boardLabels: Label[];
}
export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBarProps) {
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [textDraft, setTextDraft] = useState(filters.text);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleTextChange = useCallback(
(value: string) => {
setTextDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onChange({ ...filters, text: value });
}, 200);
},
[filters, onChange]
);
function toggleLabel(labelId: string) {
const labels = filters.labels.includes(labelId)
? filters.labels.filter((l) => l !== labelId)
: [...filters.labels, labelId];
onChange({ ...filters, labels });
}
function clearAll() {
setTextDraft("");
onChange(EMPTY_FILTER);
}
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={springs.snappy}
className="overflow-hidden border-b border-border bg-pylon-surface"
>
<div className="flex items-center gap-3 px-4 py-2">
{/* Text search */}
<div className="flex items-center gap-1.5 rounded-md bg-pylon-column px-2 py-1">
<Search className="size-3.5 text-pylon-text-secondary" />
<input
ref={inputRef}
value={textDraft}
onChange={(e) => handleTextChange(e.target.value)}
placeholder="Search cards..."
className="h-5 w-40 bg-transparent text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
/>
</div>
{/* Label filter chips */}
{boardLabels.length > 0 && (
<div className="flex items-center gap-1">
{boardLabels.map((label) => (
<button
key={label.id}
onClick={() => toggleLabel(label.id)}
className={`rounded-full px-2 py-0.5 text-xs transition-all ${
filters.labels.includes(label.id)
? "text-white"
: "opacity-40 hover:opacity-70"
}`}
style={{ backgroundColor: label.color }}
>
{label.name}
</button>
))}
</div>
)}
{/* Due date filter */}
<select
value={filters.dueDate}
onChange={(e) => onChange({ ...filters, dueDate: e.target.value as FilterState["dueDate"] })}
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
>
<option value="all">All dates</option>
<option value="overdue">Overdue</option>
<option value="week">Due this week</option>
<option value="today">Due today</option>
<option value="none">No date</option>
</select>
{/* Priority filter */}
<select
value={filters.priority}
onChange={(e) => onChange({ ...filters, priority: e.target.value as FilterState["priority"] })}
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
>
<option value="all">All priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="none">No priority</option>
</select>
{/* Spacer + clear + close */}
<div className="flex-1" />
{isFilterActive(filters) && (
<Button variant="ghost" size="sm" onClick={clearAll} className="text-xs text-pylon-text-secondary">
Clear all
</Button>
)}
<Button variant="ghost" size="icon-xs" onClick={onClose} className="text-pylon-text-secondary hover:text-pylon-text">
<X className="size-3.5" />
</Button>
</div>
</motion.div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Plus } from "lucide-react"; import { Plus, ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { fadeSlideUp, springs, staggerContainer } from "@/lib/motion";
import { useDroppable } from "@dnd-kit/core"; import { useDroppable } from "@dnd-kit/core";
import { import {
SortableContext, SortableContext,
@@ -8,8 +9,8 @@ import {
useSortable, useSortable,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ColumnHeader } from "@/components/board/ColumnHeader"; import { ColumnHeader } from "@/components/board/ColumnHeader";
import { AddCardInput } from "@/components/board/AddCardInput"; import { AddCardInput } from "@/components/board/AddCardInput";
import { CardThumbnail } from "@/components/board/CardThumbnail"; import { CardThumbnail } from "@/components/board/CardThumbnail";
@@ -24,12 +25,16 @@ const WIDTH_MAP = {
interface KanbanColumnProps { interface KanbanColumnProps {
column: Column; column: Column;
filteredCardIds?: string[];
focusedCardId?: string | null;
onCardClick?: (cardId: string) => void; onCardClick?: (cardId: string) => void;
isNew?: boolean;
} }
export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) { export function KanbanColumn({ column, filteredCardIds, focusedCardId, onCardClick, isNew }: KanbanColumnProps) {
const [showAddCard, setShowAddCard] = useState(false); const [showAddCard, setShowAddCard] = useState(false);
const board = useBoardStore((s) => s.board); const board = useBoardStore((s) => s.board);
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const width = WIDTH_MAP[column.width]; const width = WIDTH_MAP[column.width];
@@ -53,26 +58,70 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
data: { type: "column", columnId: column.id }, data: { type: "column", columnId: column.id },
}); });
const style = { const borderTop = column.color
transform: CSS.Transform.toString(transform), ? `3px solid oklch(55% 0.12 ${column.color})`
transition, : board?.color
opacity: isDragging ? 0.5 : undefined, ? `3px solid ${board.color}30`
width, : undefined;
};
const displayCardIds = filteredCardIds ?? column.cardIds;
const isFiltering = filteredCardIds != null;
const cardCount = column.cardIds.length;
const wipTint = column.wipLimit != null
? cardCount > column.wipLimit
? "oklch(70% 0.08 25 / 15%)"
: cardCount === column.wipLimit
? "oklch(75% 0.08 70 / 15%)"
: undefined
: undefined;
return ( return (
<motion.div <motion.div
ref={setSortableNodeRef} ref={setSortableNodeRef}
style={style} style={{
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column" transform: CSS.Transform.toString(transform),
initial={prefersReducedMotion ? false : { opacity: 0, x: 20 }} transition,
animate={{ opacity: 1, x: 0 }} opacity: isDragging ? 0.5 : undefined,
transition={{ type: "spring", stiffness: 300, damping: 25 }} }}
animate={{ width: column.collapsed ? 40 : width, opacity: 1 }}
initial={isNew ? { width: 0, opacity: 0 } : false}
exit={{ width: 0, opacity: 0 }}
transition={springs.bouncy}
className="shrink-0 overflow-hidden"
{...attributes} {...attributes}
>
{column.collapsed ? (
<button
onClick={() => toggleColumnCollapse(column.id)}
className="flex h-full w-full flex-col items-center gap-2 rounded-lg bg-pylon-column py-3 hover:bg-pylon-column/80 transition-colors"
style={{ borderTop }}
{...listeners}
>
<ChevronRight className="size-3.5 text-pylon-text-secondary" />
<span
className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
>
{column.title}
</span>
<span className="mt-auto font-mono text-xs text-pylon-text-secondary">
{cardCount}
</span>
</button>
) : (
<motion.section
className="group/column flex h-full flex-col overflow-hidden rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
style={{ borderTop, backgroundColor: wipTint }}
variants={fadeSlideUp}
initial={isNew || prefersReducedMotion ? false : undefined}
animate={isNew ? "visible" : undefined}
transition={springs.bouncy}
> >
{/* The column header is the drag handle for column reordering */} {/* The column header is the drag handle for column reordering */}
<div {...listeners}> <div {...listeners}>
<ColumnHeader column={column} cardCount={column.cardIds.length} /> <ColumnHeader column={column} cardCount={cardCount} filteredCount={isFiltering ? displayCardIds.length : undefined} />
</div> </div>
{/* Card list - wrapped in SortableContext for within-column sorting */} {/* Card list - wrapped in SortableContext for within-column sorting */}
@@ -80,23 +129,41 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
items={column.cardIds} items={column.cardIds}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<ScrollArea className="flex-1 overflow-y-auto"> <OverlayScrollbarsComponent
<div ref={setDroppableNodeRef} className="flex min-h-[40px] flex-col gap-2 p-2"> className="flex-1"
{column.cardIds.map((cardId) => { options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<motion.ul
ref={setDroppableNodeRef}
className="flex min-h-[40px] list-none flex-col"
style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}
variants={staggerContainer(0.03)}
initial="hidden"
animate="visible"
>
{displayCardIds.map((cardId) => {
const card = board?.cards[cardId]; const card = board?.cards[cardId];
if (!card) return null; if (!card) return null;
return ( return (
<li key={cardId}>
<CardThumbnail <CardThumbnail
key={cardId}
card={card} card={card}
boardLabels={board?.labels ?? []} boardLabels={board?.labels ?? []}
columnId={column.id} columnId={column.id}
onCardClick={onCardClick} onCardClick={onCardClick}
isFocused={focusedCardId === cardId}
/> />
</li>
); );
})} })}
</div> {displayCardIds.length === 0 && (
</ScrollArea> <li className="flex min-h-[60px] items-center justify-center rounded-md border border-dashed border-pylon-text-secondary/20 text-xs text-pylon-text-secondary/50">
{isFiltering ? "No matching cards" : "Drop or add a card"}
</li>
)}
</motion.ul>
</OverlayScrollbarsComponent>
</SortableContext> </SortableContext>
{/* Add card section */} {/* Add card section */}
@@ -118,6 +185,8 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
</Button> </Button>
</div> </div>
)} )}
</motion.section>
)}
</motion.div> </motion.div>
); );
} }

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import { formatDistanceToNow } from "date-fns";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { listBackups, restoreBackupFile, saveBoard, type BackupEntry } from "@/lib/storage";
import { useBoardStore } from "@/stores/board-store";
interface VersionHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VersionHistoryDialog({ open, onOpenChange }: VersionHistoryDialogProps) {
const board = useBoardStore((s) => s.board);
const [backups, setBackups] = useState<BackupEntry[]>([]);
const [confirmRestore, setConfirmRestore] = useState<BackupEntry | null>(null);
useEffect(() => {
if (open && board) {
listBackups(board.id).then(setBackups);
}
}, [open, board]);
async function handleRestore(backup: BackupEntry) {
if (!board) return;
// Back up current state before restoring
await saveBoard(board);
const restored = await restoreBackupFile(board.id, backup.filename);
await saveBoard(restored);
// Reload
await useBoardStore.getState().openBoard(board.id);
setConfirmRestore(null);
onOpenChange(false);
}
return (
<>
<Dialog open={open && !confirmRestore} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Version History
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Browse and restore previous versions of this board.
</DialogDescription>
</DialogHeader>
<OverlayScrollbarsComponent
className="max-h-[300px]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
{backups.length > 0 ? (
<div className="flex flex-col gap-1">
{backups.map((backup) => (
<div
key={backup.filename}
className="flex items-center justify-between rounded px-3 py-2 hover:bg-pylon-column/60"
>
<div className="flex flex-col">
<span className="text-sm text-pylon-text">
{formatDistanceToNow(new Date(backup.timestamp), { addSuffix: true })}
</span>
<span className="font-mono text-xs text-pylon-text-secondary">
{backup.cardCount} cards, {backup.columnCount} columns
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmRestore(backup)}
className="text-pylon-accent"
>
Restore
</Button>
</div>
))}
</div>
) : (
<p className="px-3 py-6 text-center text-sm text-pylon-text-secondary">
No backups yet. Backups are created automatically as you work.
</p>
)}
</OverlayScrollbarsComponent>
</DialogContent>
</Dialog>
{/* Restore confirmation */}
<Dialog open={confirmRestore != null} onOpenChange={() => setConfirmRestore(null)}>
<DialogContent className="bg-pylon-surface sm:max-w-sm">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Restore Version
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
This will replace the current board with the selected version. Your current state will be backed up first.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmRestore(null)} className="text-pylon-text-secondary">
Cancel
</Button>
<Button onClick={() => confirmRestore && handleRestore(confirmRestore)}>
Restore
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,7 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { Trash2, Copy } from "lucide-react"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { fadeSlideUp, springs, subtleHover } from "@/lib/motion";
import { Trash2, Copy, FileDown, FileSpreadsheet, Bookmark } from "lucide-react";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -21,17 +24,33 @@ import { Button } from "@/components/ui/button";
import type { BoardMeta } from "@/types/board"; import type { BoardMeta } from "@/types/board";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { deleteBoard, loadBoard, saveBoard } from "@/lib/storage"; import { useToastStore } from "@/stores/toast-store";
import { deleteBoard, loadBoard, saveBoard, saveTemplate } from "@/lib/storage";
import { exportBoardAsJson, exportBoardAsCsv } from "@/lib/import-export";
import type { BoardTemplate } from "@/types/template";
interface BoardCardProps { interface BoardCardProps {
board: BoardMeta; board: BoardMeta;
index?: number; sortable?: boolean;
} }
export function BoardCard({ board, index = 0 }: BoardCardProps) { export function BoardCard({ board, sortable = false }: BoardCardProps) {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: board.id,
disabled: !sortable,
});
const addToast = useToastStore((s) => s.addToast);
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
const addRecentBoard = useAppStore((s) => s.addRecentBoard); const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const refreshBoards = useAppStore((s) => s.refreshBoards); const refreshBoards = useAppStore((s) => s.refreshBoards);
@@ -51,6 +70,7 @@ export function BoardCard({ board, index = 0 }: BoardCardProps) {
await deleteBoard(board.id); await deleteBoard(board.id);
await refreshBoards(); await refreshBoards();
setConfirmDelete(false); setConfirmDelete(false);
addToast(`"${board.title}" deleted`, "info");
} }
async function handleDuplicate() { async function handleDuplicate() {
@@ -66,13 +86,98 @@ export function BoardCard({ board, index = 0 }: BoardCardProps) {
}; };
await saveBoard(duplicated); await saveBoard(duplicated);
await refreshBoards(); await refreshBoards();
addToast(`"${board.title}" duplicated`, "success");
}
function downloadBlob(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function handleExportJson() {
const full = await loadBoard(board.id);
const json = exportBoardAsJson(full);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(json, `${safeName}.json`, "application/json");
addToast("Board exported as JSON", "success");
}
async function handleExportCsv() {
const full = await loadBoard(board.id);
const csv = exportBoardAsCsv(full);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(csv, `${safeName}.csv`, "text/csv");
addToast("Board exported as CSV", "success");
}
async function handleSaveAsTemplate() {
const full = await loadBoard(board.id);
const { ulid } = await import("ulid");
const template: BoardTemplate = {
id: ulid(),
name: full.title,
color: full.color,
columns: full.columns.map((c) => ({
title: c.title,
width: c.width,
color: c.color,
wipLimit: c.wipLimit,
})),
labels: full.labels,
settings: full.settings,
};
await saveTemplate(template);
addToast(`Template "${full.title}" saved`, "success");
}
if (isDragging) {
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className="relative"
{...attributes}
{...listeners}
>
{/* Invisible clone to maintain grid row height */}
<div className="invisible flex flex-col rounded-lg p-4">
<div className="h-1" />
<div className="flex flex-col gap-2">
<h3 className="font-heading text-lg">&nbsp;</h3>
<p className="font-mono text-xs">&nbsp;</p>
<p className="font-mono text-xs">&nbsp;</p>
</div>
</div>
<div className="absolute -left-2 top-0 bottom-0 w-[3px] rounded-full bg-pylon-accent shadow-[0_0_6px_var(--pylon-accent),0_0_14px_var(--pylon-accent)]" />
</div>
);
} }
return ( return (
<motion.div <motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 10 }} ref={setNodeRef}
animate={{ opacity: 1, y: 0 }} style={{
transition={{ type: "spring", stiffness: 300, damping: 25, delay: index * 0.05 }} transform: CSS.Transform.toString(transform),
transition,
}}
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
transition={springs.bouncy}
whileHover={subtleHover.hover}
whileTap={subtleHover.tap}
{...attributes}
{...(sortable ? listeners : {})}
> >
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@@ -111,6 +216,19 @@ export function BoardCard({ board, index = 0 }: BoardCardProps) {
<Copy className="size-4" /> <Copy className="size-4" />
Duplicate Duplicate
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={handleSaveAsTemplate}>
<Bookmark className="size-4" />
Save as Template
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleExportJson}>
<FileDown className="size-4" />
Export as JSON
</ContextMenuItem>
<ContextMenuItem onClick={handleExportCsv}>
<FileSpreadsheet className="size-4" />
Export as CSV
</ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
variant="destructive" variant="destructive"

View File

@@ -0,0 +1,34 @@
import { formatDistanceToNow } from "date-fns";
import type { BoardMeta } from "@/types/board";
interface BoardCardOverlayProps {
board: BoardMeta;
}
export function BoardCardOverlay({ board }: BoardCardOverlayProps) {
const relativeTime = formatDistanceToNow(new Date(board.updatedAt), {
addSuffix: true,
});
return (
<div className="flex w-full flex-col rounded-lg bg-pylon-surface shadow-xl ring-2 ring-pylon-accent/40 opacity-90 cursor-grabbing">
{/* Color accent stripe */}
<div
className="h-1 w-full rounded-t-lg"
style={{ backgroundColor: board.color }}
/>
<div className="flex flex-col gap-2 p-4">
<h3 className="font-heading text-lg text-pylon-text">
{board.title}
</h3>
<p className="font-mono text-xs text-pylon-text-secondary">
{board.cardCount} card{board.cardCount !== 1 ? "s" : ""} &middot;{" "}
{board.columnCount} column{board.columnCount !== 1 ? "s" : ""}
</p>
<p className="font-mono text-xs text-pylon-text-secondary">
{relativeTime}
</p>
</div>
</div>
);
}

View File

@@ -1,14 +1,63 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { Plus } from "lucide-react"; import { Plus, ArrowUpDown } from "lucide-react";
import { motion } from "framer-motion";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import {
DndContext,
DragOverlay,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
rectSortingStrategy,
} from "@dnd-kit/sortable";
import { staggerContainer, scaleIn, springs } from "@/lib/motion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { BoardCard } from "@/components/boards/BoardCard"; import { BoardCard } from "@/components/boards/BoardCard";
import { BoardCardOverlay } from "@/components/boards/BoardCardOverlay";
import { NewBoardDialog } from "@/components/boards/NewBoardDialog"; import { NewBoardDialog } from "@/components/boards/NewBoardDialog";
import { ImportExportButtons } from "@/components/import-export/ImportExportButtons"; import { ImportButton } from "@/components/import-export/ImportExportButtons";
import type { BoardSortOrder } from "@/types/settings";
const SORT_LABELS: Record<BoardSortOrder, string> = {
manual: "Manual",
title: "Name",
updated: "Last modified",
created: "Date created",
};
export function BoardList() { export function BoardList() {
const boards = useAppStore((s) => s.boards); const boards = useAppStore((s) => s.boards);
const sortOrder = useAppStore((s) => s.settings.boardSortOrder);
const setSortOrder = useAppStore((s) => s.setBoardSortOrder);
const setBoardManualOrder = useAppStore((s) => s.setBoardManualOrder);
const getSortedBoards = useAppStore((s) => s.getSortedBoards);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [activeBoardId, setActiveBoardId] = useState<string | null>(null);
const sortedBoards = getSortedBoards();
const isManual = sortOrder === "manual";
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(KeyboardSensor)
);
// Listen for custom event to open new board dialog from command palette // Listen for custom event to open new board dialog from command palette
useEffect(() => { useEffect(() => {
@@ -21,36 +70,110 @@ export function BoardList() {
}; };
}, []); }, []);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveBoardId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveBoardId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const currentOrder = useAppStore.getState().getSortedBoards().map((b) => b.id);
const oldIndex = currentOrder.indexOf(active.id as string);
const newIndex = currentOrder.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = [...currentOrder];
newOrder.splice(oldIndex, 1);
newOrder.splice(newIndex, 0, active.id as string);
setBoardManualOrder(newOrder);
},
[setBoardManualOrder]
);
const handleDragCancel = useCallback(() => {
setActiveBoardId(null);
}, []);
if (boards.length === 0) { if (boards.length === 0) {
return ( return (
<> <>
<div className="flex h-full flex-col items-center justify-center gap-4"> <motion.div
<p className="font-mono text-sm text-pylon-text-secondary"> className="flex h-full flex-col items-center justify-center gap-6"
Create your first board variants={scaleIn}
initial="hidden"
animate="visible"
transition={springs.gentle}
>
<div className="text-center">
<h2 className="font-heading text-2xl text-pylon-text">
Welcome to OpenPylon
</h2>
<p className="mt-2 max-w-sm text-sm text-pylon-text-secondary">
A local-first Kanban board that keeps your data on your machine.
Create your first board to get started.
</p> </p>
<div className="flex items-center gap-2"> </div>
<Button onClick={() => setDialogOpen(true)}> <div className="flex items-center gap-3">
<Button size="lg" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
New Board Create Board
</Button> </Button>
<ImportExportButtons /> <ImportButton />
</div>
</div> </div>
</motion.div>
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</> </>
); );
} }
const activeBoard = activeBoardId
? sortedBoards.find((b) => b.id === activeBoardId)
: null;
return ( return (
<> <>
<div className="h-full overflow-y-auto p-6"> <OverlayScrollbarsComponent
className="h-full"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true } }}
defer
>
<div className="p-6">
{/* Heading row */} {/* Heading row */}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"> <h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
Your Boards Your Boards
</h2> </h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ImportExportButtons /> {/* Sort dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ArrowUpDown className="size-3.5" />
<span className="font-mono text-xs">{SORT_LABELS[sortOrder]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sortOrder}
onValueChange={(v) => setSortOrder(v as BoardSortOrder)}
>
{(Object.keys(SORT_LABELS) as BoardSortOrder[]).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{SORT_LABELS[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ImportButton />
<Button size="sm" onClick={() => setDialogOpen(true)}> <Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
New New
@@ -59,12 +182,35 @@ export function BoardList() {
</div> </div>
{/* Board grid */} {/* Board grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <DndContext
{boards.map((board, index) => ( sensors={sensors}
<BoardCard key={board.id} board={board} index={index} /> collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={sortedBoards.map((b) => b.id)}
strategy={rectSortingStrategy}
>
<motion.div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
{sortedBoards.map((board) => (
<BoardCard key={board.id} board={board} sortable={isManual} />
))} ))}
</motion.div>
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeBoard ? <BoardCardOverlay board={activeBoard} /> : null}
</DragOverlay>
</DndContext>
</div> </div>
</div> </OverlayScrollbarsComponent>
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</> </>

View File

@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { X } from "lucide-react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -9,10 +10,11 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { createBoard } from "@/lib/board-factory"; import { createBoard, createBoardFromTemplate } from "@/lib/board-factory";
import { saveBoard } from "@/lib/storage"; import { saveBoard, listTemplates, deleteTemplate } from "@/lib/storage";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import type { BoardTemplate } from "@/types/template";
const PRESET_COLORS = [ const PRESET_COLORS = [
"#6366f1", // indigo "#6366f1", // indigo
@@ -36,6 +38,8 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]); const [color, setColor] = useState(PRESET_COLORS[0]);
const [template, setTemplate] = useState<Template>("blank"); const [template, setTemplate] = useState<Template>("blank");
const [selectedUserTemplate, setSelectedUserTemplate] = useState<BoardTemplate | null>(null);
const [userTemplates, setUserTemplates] = useState<BoardTemplate[]>([]);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const refreshBoards = useAppStore((s) => s.refreshBoards); const refreshBoards = useAppStore((s) => s.refreshBoards);
@@ -43,13 +47,27 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
const addRecentBoard = useAppStore((s) => s.addRecentBoard); const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const openBoard = useBoardStore((s) => s.openBoard); const openBoard = useBoardStore((s) => s.openBoard);
useEffect(() => {
if (open) {
listTemplates().then(setUserTemplates);
}
}, [open]);
async function handleCreate() { async function handleCreate() {
const trimmed = title.trim(); const trimmed = title.trim();
if (!trimmed || creating) return; if (!trimmed || creating) return;
setCreating(true); setCreating(true);
try { try {
const board = createBoard(trimmed, color, template); const board = selectedUserTemplate
? createBoardFromTemplate(selectedUserTemplate, trimmed)
: createBoard(trimmed, color, template);
if (selectedUserTemplate) {
// Use color from template, but override if user picked a different color
// (we keep template color by default)
} else {
// color already set on board via createBoard
}
await saveBoard(board); await saveBoard(board);
await refreshBoards(); await refreshBoards();
await openBoard(board.id); await openBoard(board.id);
@@ -66,6 +84,15 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
setTitle(""); setTitle("");
setColor(PRESET_COLORS[0]); setColor(PRESET_COLORS[0]);
setTemplate("blank"); setTemplate("blank");
setSelectedUserTemplate(null);
}
async function handleDeleteTemplate(templateId: string) {
await deleteTemplate(templateId);
setUserTemplates((prev) => prev.filter((t) => t.id !== templateId));
if (selectedUserTemplate?.id === templateId) {
setSelectedUserTemplate(null);
}
} }
function handleKeyDown(e: React.KeyboardEvent) { function handleKeyDown(e: React.KeyboardEvent) {
@@ -135,19 +162,43 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
<label className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary"> <label className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Template Template
</label> </label>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{(["blank", "kanban", "sprint"] as const).map((t) => ( {(["blank", "kanban", "sprint"] as const).map((t) => (
<Button <Button
key={t} key={t}
type="button" type="button"
variant={template === t ? "default" : "outline"} variant={template === t && !selectedUserTemplate ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setTemplate(t)} onClick={() => { setTemplate(t); setSelectedUserTemplate(null); }}
className="capitalize" className="capitalize"
> >
{t} {t}
</Button> </Button>
))} ))}
{userTemplates.map((ut) => (
<div key={ut.id} className="flex items-center gap-0.5">
<Button
type="button"
variant={selectedUserTemplate?.id === ut.id ? "default" : "outline"}
size="sm"
onClick={() => { setSelectedUserTemplate(ut); setColor(ut.color); }}
>
<span
className="inline-block size-2 rounded-full shrink-0"
style={{ backgroundColor: ut.color }}
/>
{ut.name}
</Button>
<button
type="button"
onClick={() => handleDeleteTemplate(ut.id)}
className="rounded p-0.5 text-pylon-text-secondary opacity-0 hover:opacity-100 hover:bg-pylon-danger/10 hover:text-pylon-danger transition-opacity"
title="Delete template"
>
<X className="size-3" />
</button>
</div>
))}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,23 +1,43 @@
import { FileIcon, X, Plus } from "lucide-react"; import { FileIcon, X, Plus, ExternalLink } from "lucide-react";
import { openPath } from "@tauri-apps/plugin-opener";
import { open } from "@tauri-apps/plugin-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { copyAttachment } from "@/lib/storage";
import type { Attachment } from "@/types/board"; import type { Attachment } from "@/types/board";
interface AttachmentSectionProps { interface AttachmentSectionProps {
cardId: string; cardId: string;
attachments: Attachment[]; attachments: Attachment[];
attachmentMode: "link" | "copy";
} }
export function AttachmentSection({ export function AttachmentSection({
cardId, cardId,
attachments, attachments,
}: AttachmentSectionProps) { }: AttachmentSectionProps) {
const addAttachment = useBoardStore((s) => s.addAttachment);
const removeAttachment = useBoardStore((s) => s.removeAttachment); const removeAttachment = useBoardStore((s) => s.removeAttachment);
function handleAdd() { async function handleAdd() {
// Placeholder: Tauri file dialog will be wired in a later task const selected = await open({
console.log("Add attachment (file dialog not yet wired)"); multiple: false,
title: "Select attachment",
});
if (!selected) return;
const fileName = selected.split(/[\\/]/).pop() ?? "attachment";
const board = useBoardStore.getState().board;
if (!board) return;
const mode = board.settings.attachmentMode;
if (mode === "copy") {
const destPath = await copyAttachment(board.id, selected, fileName);
addAttachment(cardId, { name: fileName, path: destPath, mode: "copy" });
} else {
addAttachment(cardId, { name: fileName, path: selected, mode: "link" });
}
} }
return ( return (
@@ -49,6 +69,13 @@ export function AttachmentSection({
<span className="flex-1 truncate text-sm text-pylon-text"> <span className="flex-1 truncate text-sm text-pylon-text">
{att.name} {att.name}
</span> </span>
<button
onClick={() => openPath(att.path)}
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-accent/10 hover:text-pylon-accent group-hover/att:opacity-100"
aria-label="Open attachment"
>
<ExternalLink className="size-3" />
</button>
<button <button
onClick={() => removeAttachment(cardId, att.id)} onClick={() => removeAttachment(cardId, att.id)}
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/att:opacity-100" className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/att:opacity-100"

View File

@@ -0,0 +1,280 @@
import { useState, useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameDay,
isSameMonth,
isToday as isTodayFn,
isPast,
addMonths,
subMonths,
setMonth,
setYear,
getYear,
getMonth,
} from "date-fns";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
interface CalendarPopoverProps {
selectedDate: Date | null;
onSelect: (date: Date) => void;
onClear: () => void;
children: React.ReactNode;
}
type ViewMode = "days" | "months" | "years";
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
export function CalendarPopover({
selectedDate,
onSelect,
onClear,
children,
}: CalendarPopoverProps) {
const [open, setOpen] = useState(false);
const [viewDate, setViewDate] = useState(() => selectedDate ?? new Date());
const [viewMode, setViewMode] = useState<ViewMode>("days");
// Reset view when opening
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
setViewDate(selectedDate ?? new Date());
setViewMode("days");
}
setOpen(nextOpen);
}
function handleSelectDate(date: Date) {
onSelect(date);
setOpen(false);
}
function handleToday() {
const today = new Date();
onSelect(today);
setOpen(false);
}
function handleClear() {
onClear();
setOpen(false);
}
// Build the 6x7 grid of days for the current viewDate month
const calendarDays = useMemo(() => {
const monthStart = startOfMonth(viewDate);
const monthEnd = endOfMonth(viewDate);
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [viewDate]);
// Year range for year selector: current year +/- 5
const yearRange = useMemo(() => {
const center = getYear(viewDate);
const years: number[] = [];
for (let y = center - 5; y <= center + 5; y++) {
years.push(y);
}
return years;
}, [viewDate]);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-[280px] bg-pylon-surface p-0 rounded-xl shadow-2xl border-border"
>
{/* Navigation header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<Button
variant="ghost"
size="icon-xs"
onClick={() => setViewDate((d) => subMonths(d, 1))}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ChevronLeft className="size-4" />
</Button>
<div className="flex items-center gap-1">
<button
onClick={() => setViewMode(viewMode === "months" ? "days" : "months")}
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
>
{format(viewDate, "MMMM")}
</button>
<button
onClick={() => setViewMode(viewMode === "years" ? "days" : "years")}
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
>
{format(viewDate, "yyyy")}
</button>
</div>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setViewDate((d) => addMonths(d, 1))}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ChevronRight className="size-4" />
</Button>
</div>
{/* Body: days / months / years */}
<div className="p-3">
<AnimatePresence mode="wait">
{viewMode === "days" && (
<motion.div
key="days"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Weekday headers */}
<div className="mb-1 grid grid-cols-7">
{WEEKDAYS.map((wd) => (
<div
key={wd}
className="flex h-8 items-center justify-center font-mono text-[10px] uppercase tracking-wider text-pylon-text-secondary"
>
{wd}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{calendarDays.map((day) => {
const inMonth = isSameMonth(day, viewDate);
const today = isTodayFn(day);
const selected = selectedDate != null && isSameDay(day, selectedDate);
const past = isPast(day) && !today;
if (!inMonth) {
return <div key={day.toISOString()} className="h-9" />;
}
return (
<button
key={day.toISOString()}
onClick={() => handleSelectDate(day)}
className={`flex h-9 items-center justify-center rounded-lg text-sm transition-colors
${selected
? "bg-pylon-accent font-medium text-white"
: today
? "font-medium text-pylon-accent ring-1 ring-pylon-accent"
: past
? "text-pylon-text opacity-50 hover:bg-pylon-column hover:opacity-75"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{format(day, "d")}
</button>
);
})}
</div>
</motion.div>
)}
{viewMode === "months" && (
<motion.div
key="months"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="grid grid-cols-3 gap-1"
>
{MONTH_NAMES.map((name, i) => (
<button
key={name}
onClick={() => {
setViewDate((d) => setMonth(d, i));
setViewMode("days");
}}
className={`rounded-lg py-2 text-sm transition-colors ${
getMonth(viewDate) === i
? "bg-pylon-accent font-medium text-white"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{name}
</button>
))}
</motion.div>
)}
{viewMode === "years" && (
<motion.div
key="years"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="grid grid-cols-3 gap-1"
>
{yearRange.map((year) => (
<button
key={year}
onClick={() => {
setViewDate((d) => setYear(d, year));
setViewMode("days");
}}
className={`rounded-lg py-2 text-sm transition-colors ${
getYear(viewDate) === year
? "bg-pylon-accent font-medium text-white"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{year}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={handleToday}
className="text-pylon-accent hover:text-pylon-accent"
>
Today
</Button>
<Button
variant="ghost"
size="xs"
onClick={handleClear}
className="text-pylon-text-secondary hover:text-pylon-danger"
>
Clear
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,18 +1,16 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { import { AnimatePresence, motion } from "framer-motion";
Dialog, import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
DialogContent, import { X } from "lucide-react";
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor"; import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor";
import { ChecklistSection } from "@/components/card-detail/ChecklistSection"; import { ChecklistSection } from "@/components/card-detail/ChecklistSection";
import { LabelPicker } from "@/components/card-detail/LabelPicker"; import { LabelPicker } from "@/components/card-detail/LabelPicker";
import { DueDatePicker } from "@/components/card-detail/DueDatePicker"; import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
import { AttachmentSection } from "@/components/card-detail/AttachmentSection"; import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
import { PriorityPicker } from "@/components/card-detail/PriorityPicker";
import { CommentsSection } from "@/components/card-detail/CommentsSection";
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
interface CardDetailModalProps { interface CardDetailModalProps {
cardId: string | null; cardId: string | null;
@@ -24,89 +22,196 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
cardId ? s.board?.cards[cardId] ?? null : null cardId ? s.board?.cards[cardId] ?? null : null
); );
const boardLabels = useBoardStore((s) => s.board?.labels ?? []); const boardLabels = useBoardStore((s) => s.board?.labels ?? []);
const attachmentMode = useBoardStore(
(s) => s.board?.settings.attachmentMode ?? "link"
);
const updateCard = useBoardStore((s) => s.updateCard); const updateCard = useBoardStore((s) => s.updateCard);
const open = cardId != null && card != null; const open = cardId != null && card != null;
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> <AnimatePresence>
<DialogContent className="max-w-3xl gap-0 overflow-hidden p-0"> {open && card && cardId && (
{card && cardId && (
<> <>
{/* Hidden accessible description */} {/* Backdrop */}
<DialogDescription className="sr-only"> <motion.div
Card detail editor className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
</DialogDescription> initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
<div className="flex max-h-[80vh] flex-col sm:flex-row"> {/* Modal */}
{/* Left panel: Title + Markdown (60%) */} <div
<div className="flex flex-1 flex-col overflow-y-auto p-6 sm:w-[60%]"> className="fixed inset-0 z-50 flex items-center justify-center p-4"
<DialogHeader className="mb-4"> onClick={onClose}
>
<motion.div
layoutId={`card-${cardId}`}
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
transition={springs.gentle}
onClick={(e) => e.stopPropagation()}
>
<EscapeHandler onClose={onClose} />
<span className="sr-only">Card detail editor</span>
{/* Header: cover color background + title + close */}
<div
className="relative flex items-center gap-3 px-6 py-4"
style={{
backgroundColor: card.coverColor
? `oklch(55% 0.12 ${card.coverColor})`
: undefined,
}}
>
<InlineTitle <InlineTitle
cardId={cardId} cardId={cardId}
title={card.title} title={card.title}
updateCard={updateCard} updateCard={updateCard}
hasColor={card.coverColor != null}
/> />
</DialogHeader> <button
onClick={onClose}
<MarkdownEditor cardId={cardId} value={card.description} /> className={`absolute right-4 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors ${
card.coverColor
? "text-white/70 hover:bg-white/20 hover:text-white"
: "text-pylon-text-secondary hover:bg-pylon-column hover:text-pylon-text"
}`}
aria-label="Close"
>
<X className="size-5" />
</button>
</div> </div>
{/* Vertical separator */} {/* Dashboard grid body */}
<Separator orientation="vertical" className="hidden sm:block" /> <OverlayScrollbarsComponent
className="max-h-[calc(85vh-4rem)]"
{/* Right sidebar (40%) */} options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
<div className="flex flex-col gap-5 overflow-y-auto border-t border-border p-5 sm:w-[40%] sm:border-t-0"> defer
>
<motion.div
className="grid grid-cols-2 gap-4 p-5"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
{/* Row 1: Labels + Due Date */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<LabelPicker <LabelPicker
cardId={cardId} cardId={cardId}
cardLabelIds={card.labels} cardLabelIds={card.labels}
boardLabels={boardLabels} boardLabels={boardLabels}
/> />
</motion.div>
<Separator /> <motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<DueDatePicker cardId={cardId} dueDate={card.dueDate} /> <DueDatePicker cardId={cardId} dueDate={card.dueDate} />
</motion.div>
<Separator /> {/* Row 2: Checklist + Description */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<ChecklistSection <ChecklistSection
cardId={cardId} cardId={cardId}
checklist={card.checklist} checklist={card.checklist}
/> />
</motion.div>
<Separator /> <motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<MarkdownEditor cardId={cardId} value={card.description} />
</motion.div>
{/* Row 3: Priority + Cover */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<PriorityPicker cardId={cardId} priority={card.priority} />
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CoverColorPicker
cardId={cardId}
coverColor={card.coverColor}
/>
</motion.div>
{/* Row 4: Attachments (full width) */}
<motion.div
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<AttachmentSection <AttachmentSection
cardId={cardId} cardId={cardId}
attachments={card.attachments} attachments={card.attachments}
attachmentMode={attachmentMode}
/> />
</div> </motion.div>
{/* Row 5: Comments (full width) */}
<motion.div
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CommentsSection cardId={cardId} comments={card.comments} />
</motion.div>
</motion.div>
</OverlayScrollbarsComponent>
</motion.div>
</div> </div>
</> </>
)} )}
</DialogContent> </AnimatePresence>
</Dialog>
); );
} }
/* ---------- Escape key handler ---------- */
function EscapeHandler({ onClose }: { onClose: () => void }) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return null;
}
/* ---------- Inline editable title ---------- */ /* ---------- Inline editable title ---------- */
interface InlineTitleProps { interface InlineTitleProps {
cardId: string; cardId: string;
title: string; title: string;
updateCard: (cardId: string, updates: { title: string }) => void; updateCard: (cardId: string, updates: { title: string }) => void;
hasColor: boolean;
} }
function InlineTitle({ cardId, title, updateCard }: InlineTitleProps) { function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title); const [draft, setDraft] = useState(title);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Sync when title changes externally
useEffect(() => { useEffect(() => {
setDraft(title); setDraft(title);
}, [title]); }, [title]);
@@ -138,6 +243,9 @@ function InlineTitle({ cardId, title, updateCard }: InlineTitleProps) {
} }
} }
const textColor = hasColor ? "text-white" : "text-pylon-text";
const hoverColor = hasColor ? "hover:text-white/80" : "hover:text-pylon-accent";
if (editing) { if (editing) {
return ( return (
<input <input
@@ -146,17 +254,74 @@ function InlineTitle({ cardId, title, updateCard }: InlineTitleProps) {
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={handleSave} onBlur={handleSave}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="font-heading text-xl text-pylon-text bg-transparent outline-none border-b border-pylon-accent pb-0.5" className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${
hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent"
}`}
/> />
); );
} }
return ( return (
<DialogTitle <h2
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent" className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
> >
{title} {title}
</DialogTitle> </h2>
);
}
/* ---------- Cover color picker ---------- */
function CoverColorPicker({
cardId,
coverColor,
}: {
cardId: string;
coverColor: string | null;
}) {
const updateCard = useBoardStore((s) => s.updateCard);
const presets = [
{ hue: "160", label: "Teal" },
{ hue: "240", label: "Blue" },
{ hue: "300", label: "Purple" },
{ hue: "350", label: "Pink" },
{ hue: "25", label: "Red" },
{ hue: "55", label: "Orange" },
{ hue: "85", label: "Yellow" },
{ hue: "130", label: "Lime" },
{ hue: "200", label: "Cyan" },
{ hue: "0", label: "Slate" },
];
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Cover
</h4>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => updateCard(cardId, { coverColor: null })}
className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
title="None"
>
&times;
</button>
{presets.map(({ hue, label }) => (
<button
key={hue}
onClick={() => updateCard(cardId, { coverColor: hue })}
className="size-6 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: `oklch(55% 0.12 ${hue})`,
outline:
coverColor === hue ? "2px solid currentColor" : "none",
outlineOffset: "1px",
}}
title={label}
/>
))}
</div>
</div>
); );
} }

View File

@@ -1,5 +1,20 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { X } from "lucide-react"; import { GripVertical, X } from "lucide-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import type { ChecklistItem } from "@/types/board"; import type { ChecklistItem } from "@/types/board";
@@ -13,8 +28,23 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
const updateChecklistItem = useBoardStore((s) => s.updateChecklistItem); const updateChecklistItem = useBoardStore((s) => s.updateChecklistItem);
const deleteChecklistItem = useBoardStore((s) => s.deleteChecklistItem); const deleteChecklistItem = useBoardStore((s) => s.deleteChecklistItem);
const addChecklistItem = useBoardStore((s) => s.addChecklistItem); const addChecklistItem = useBoardStore((s) => s.addChecklistItem);
const reorderChecklistItems = useBoardStore((s) => s.reorderChecklistItems);
const [newItemText, setNewItemText] = useState(""); const [newItemText, setNewItemText] = useState("");
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 3 } })
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = checklist.findIndex((item) => item.id === active.id);
const newIndex = checklist.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderChecklistItems(cardId, oldIndex, newIndex);
}
}
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const checked = checklist.filter((item) => item.checked).length; const checked = checklist.filter((item) => item.checked).length;
@@ -37,7 +67,8 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* Header */} {/* Header + progress */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary"> <h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Checklist Checklist
@@ -48,8 +79,24 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
</span> </span>
)} )}
</div> </div>
{checklist.length > 0 && (
<div className="h-1 w-full overflow-hidden rounded-full bg-pylon-column">
<div
className="h-full rounded-full bg-pylon-accent transition-all duration-300"
style={{ width: `${(checked / checklist.length) * 100}%` }}
/>
</div>
)}
</div>
{/* Items */} {/* Items */}
<OverlayScrollbarsComponent
className="max-h-[160px]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={checklist.map((item) => item.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{checklist.map((item) => ( {checklist.map((item) => (
<ChecklistRow <ChecklistRow
@@ -62,6 +109,9 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
/> />
))} ))}
</div> </div>
</SortableContext>
</DndContext>
</OverlayScrollbarsComponent>
{/* Add item */} {/* Add item */}
<div className="flex gap-2"> <div className="flex gap-2">
@@ -90,6 +140,10 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(item.text); const [draft, setDraft] = useState(item.text);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
});
function handleSave() { function handleSave() {
const trimmed = draft.trim(); const trimmed = draft.trim();
if (trimmed && trimmed !== item.text) { if (trimmed && trimmed !== item.text) {
@@ -111,7 +165,22 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
} }
return ( return (
<div className="group/item flex items-center gap-2 rounded px-1 py-0.5 hover:bg-pylon-column/60"> <div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
}}
className="group/item flex items-center gap-2 rounded px-1 py-0.5 hover:bg-pylon-column/60"
{...attributes}
>
<span
className="shrink-0 cursor-grab text-pylon-text-secondary opacity-0 group-hover/item:opacity-100"
{...listeners}
>
<GripVertical className="size-3" />
</span>
<input <input
type="checkbox" type="checkbox"
checked={item.checked} checked={item.checked}

View File

@@ -0,0 +1,97 @@
import { useState, useRef } from "react";
import { formatDistanceToNow } from "date-fns";
import { X } from "lucide-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button";
import { useBoardStore } from "@/stores/board-store";
import type { Comment } from "@/types/board";
interface CommentsSectionProps {
cardId: string;
comments: Comment[];
}
export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
const addComment = useBoardStore((s) => s.addComment);
const deleteComment = useBoardStore((s) => s.deleteComment);
const [draft, setDraft] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
function handleAdd() {
const trimmed = draft.trim();
if (!trimmed) return;
addComment(cardId, trimmed);
setDraft("");
textareaRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleAdd();
}
}
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Comments
</h4>
{/* Add comment */}
<div className="flex gap-2">
<textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a comment... (Enter to send, Shift+Enter for newline)"
rows={2}
className="flex-1 resize-none rounded-md bg-pylon-column px-2 py-1.5 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
/>
<Button
size="sm"
onClick={handleAdd}
disabled={!draft.trim()}
className="self-end"
>
Add
</Button>
</div>
{/* Comment list */}
{comments.length > 0 && (
<OverlayScrollbarsComponent
className="max-h-[200px]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<div className="flex flex-col gap-2">
{comments.map((comment) => (
<div
key={comment.id}
className="group/comment flex gap-2 rounded px-2 py-1.5 hover:bg-pylon-column/60"
>
<div className="flex-1">
<p className="whitespace-pre-wrap text-sm text-pylon-text">
{comment.text}
</p>
<span className="font-mono text-[10px] text-pylon-text-secondary">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</span>
</div>
<button
onClick={() => deleteComment(cardId, comment.id)}
className="shrink-0 self-start rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/comment:opacity-100"
aria-label="Delete comment"
>
<X className="size-3" />
</button>
</div>
))}
</div>
</OverlayScrollbarsComponent>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { format, formatDistanceToNow, isPast, isToday } from "date-fns"; import { format, formatDistanceToNow, isPast, isToday } from "date-fns";
import { Button } from "@/components/ui/button"; import { X } from "lucide-react";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { CalendarPopover } from "@/components/card-detail/CalendarPopover";
interface DueDatePickerProps { interface DueDatePickerProps {
cardId: string; cardId: string;
@@ -13,30 +14,41 @@ export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
const dateObj = dueDate ? new Date(dueDate) : null; const dateObj = dueDate ? new Date(dueDate) : null;
const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj); const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { function handleSelect(date: Date) {
const val = e.target.value; updateCard(cardId, { dueDate: format(date, "yyyy-MM-dd") });
updateCard(cardId, { dueDate: val || null });
} }
function handleClear() { function handleClear() {
updateCard(cardId, { dueDate: null }); updateCard(cardId, { dueDate: null });
} }
// Format the date value for the HTML date input (YYYY-MM-DD)
const inputValue = dateObj
? format(dateObj, "yyyy-MM-dd")
: "";
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* Header */} {/* Header with clear button */}
<div className="flex items-center justify-between">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary"> <h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Due Date Due Date
</h4> </h4>
{dueDate && (
<button
onClick={handleClear}
className="rounded p-0.5 text-pylon-text-secondary transition-colors hover:bg-pylon-danger/10 hover:text-pylon-danger"
aria-label="Clear due date"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* Current date display */} {/* Clickable date display -> opens calendar */}
{dateObj && ( <CalendarPopover
<div className="flex items-center gap-2"> selectedDate={dateObj}
onSelect={handleSelect}
onClear={handleClear}
>
<button className="flex w-full items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-pylon-column/60">
{dateObj ? (
<>
<span <span
className={`text-sm font-medium ${ className={`text-sm font-medium ${
overdue ? "text-pylon-danger" : "text-pylon-text" overdue ? "text-pylon-danger" : "text-pylon-text"
@@ -55,28 +67,14 @@ export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
? "today" ? "today"
: `in ${formatDistanceToNow(dateObj)}`} : `in ${formatDistanceToNow(dateObj)}`}
</span> </span>
</div> </>
) : (
<span className="text-sm italic text-pylon-text-secondary/60">
Click to set date...
</span>
)} )}
</button>
{/* Date input + clear */} </CalendarPopover>
<div className="flex items-center gap-2">
<input
type="date"
value={inputValue}
onChange={handleChange}
className="h-7 rounded-md border border-pylon-text-secondary/20 bg-pylon-column px-2 text-xs text-pylon-text outline-none focus:border-pylon-accent focus:ring-1 focus:ring-pylon-accent"
/>
{dueDate && (
<Button
variant="ghost"
size="xs"
onClick={handleClear}
className="text-pylon-text-secondary hover:text-pylon-danger"
>
Clear
</Button>
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Plus, Check } from "lucide-react"; import { Plus, Check } from "lucide-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Popover, Popover,
@@ -81,7 +82,12 @@ export function LabelPicker({
{/* Existing labels */} {/* Existing labels */}
{boardLabels.length > 0 && ( {boardLabels.length > 0 && (
<div className="flex max-h-40 flex-col gap-1 overflow-y-auto"> <OverlayScrollbarsComponent
className="max-h-40"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<div className="flex flex-col gap-1">
{boardLabels.map((label) => { {boardLabels.map((label) => {
const isSelected = cardLabelIds.includes(label.id); const isSelected = cardLabelIds.includes(label.id);
return ( return (
@@ -104,6 +110,7 @@ export function LabelPicker({
); );
})} })}
</div> </div>
</OverlayScrollbarsComponent>
)} )}
{/* Create new label */} {/* Create new label */}

View File

@@ -1,9 +1,14 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
const OS_OPTIONS = {
scrollbars: { theme: "os-theme-pylon" as const, autoHide: "scroll" as const, autoHideDelay: 600, clickScroll: true },
};
interface MarkdownEditorProps { interface MarkdownEditorProps {
cardId: string; cardId: string;
value: string; value: string;
@@ -21,10 +26,13 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
setDraft(value); setDraft(value);
}, [value]); }, [value]);
// Auto-focus textarea when switching to edit mode // Auto-focus and auto-size textarea when switching to edit mode
useEffect(() => { useEffect(() => {
if (mode === "edit" && textareaRef.current) { if (mode === "edit" && textareaRef.current) {
textareaRef.current.focus(); const el = textareaRef.current;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
el.focus();
} }
}, [mode]); }, [mode]);
@@ -41,6 +49,10 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
const text = e.target.value; const text = e.target.value;
setDraft(text); setDraft(text);
// Auto-size textarea to fit content (parent OverlayScrollbars handles overflow)
e.target.style.height = "auto";
e.target.style.height = e.target.scrollHeight + "px";
// Debounced auto-save // Debounced auto-save
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
@@ -90,17 +102,25 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
{/* Editor / Preview */} {/* Editor / Preview */}
{mode === "edit" ? ( {mode === "edit" ? (
<OverlayScrollbarsComponent
className="max-h-[160px] rounded-md border border-pylon-text-secondary/20 bg-pylon-surface focus-within:border-pylon-accent focus-within:ring-1 focus-within:ring-pylon-accent"
options={{ ...OS_OPTIONS, overflow: { x: "hidden" as const } }}
defer
>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={draft} value={draft}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
placeholder="Add a description... (Markdown supported)" placeholder="Add a description... (Markdown supported)"
className="min-h-[200px] w-full resize-y rounded-md border border-pylon-text-secondary/20 bg-pylon-surface px-3 py-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:border-pylon-accent focus:ring-1 focus:ring-pylon-accent" className="min-h-[100px] w-full resize-none overflow-hidden bg-transparent px-3 py-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
/> />
</OverlayScrollbarsComponent>
) : ( ) : (
<div <OverlayScrollbarsComponent
className="min-h-[200px] cursor-pointer rounded-md border border-transparent px-1 py-1 transition-colors hover:border-pylon-text-secondary/20" className="min-h-[100px] max-h-[160px] cursor-pointer rounded-md border border-transparent px-1 py-1 transition-colors hover:border-pylon-text-secondary/20"
options={{ ...OS_OPTIONS, overflow: { x: "hidden" as const } }}
defer
onClick={() => setMode("edit")} onClick={() => setMode("edit")}
> >
{draft ? ( {draft ? (
@@ -114,7 +134,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
Click to add a description... Click to add a description...
</p> </p>
)} )}
</div> </OverlayScrollbarsComponent>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,46 @@
import { useBoardStore } from "@/stores/board-store";
import type { Priority } from "@/types/board";
const PRIORITIES: { value: Priority; label: string; color: string }[] = [
{ value: "none", label: "None", color: "oklch(50% 0 0 / 30%)" },
{ value: "low", label: "Low", color: "oklch(60% 0.15 240)" },
{ value: "medium", label: "Medium", color: "oklch(70% 0.15 85)" },
{ value: "high", label: "High", color: "oklch(60% 0.15 55)" },
{ value: "urgent", label: "Urgent", color: "oklch(55% 0.15 25)" },
];
interface PriorityPickerProps {
cardId: string;
priority: Priority;
}
export function PriorityPicker({ cardId, priority }: PriorityPickerProps) {
const updateCard = useBoardStore((s) => s.updateCard);
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Priority
</h4>
<div className="flex flex-wrap gap-1.5">
{PRIORITIES.map(({ value, label, color }) => (
<button
key={value}
onClick={() => updateCard(cardId, { priority: value })}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
priority === value
? "text-white shadow-sm"
: "text-pylon-text-secondary hover:text-pylon-text"
}`}
style={{
backgroundColor: priority === value ? color : undefined,
border: priority !== value ? `1px solid ${color}` : `1px solid transparent`,
}}
>
{label}
</button>
))}
</div>
</div>
);
}

View File

@@ -1,56 +1,23 @@
import { useRef } from "react"; import { useRef } from "react";
import { Download, Upload } from "lucide-react"; import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { useToastStore } from "@/stores/toast-store";
import { saveBoard } from "@/lib/storage"; import { saveBoard } from "@/lib/storage";
import { import {
exportBoardAsJson,
exportBoardAsCsv,
importBoardFromJson, importBoardFromJson,
importFromTrelloJson, importFromTrelloJson,
} from "@/lib/import-export"; } from "@/lib/import-export";
function downloadBlob(content: string, filename: string, mimeType: string) { export function ImportButton() {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function ImportExportButtons() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const board = useBoardStore((s) => s.board); const addToast = useToastStore((s) => s.addToast);
const refreshBoards = useAppStore((s) => s.refreshBoards); const refreshBoards = useAppStore((s) => s.refreshBoards);
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
const addRecentBoard = useAppStore((s) => s.addRecentBoard); const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const openBoard = useBoardStore((s) => s.openBoard); const openBoard = useBoardStore((s) => s.openBoard);
function handleExportJson() {
if (!board) return;
const json = exportBoardAsJson(board);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(json, `${safeName}.json`, "application/json");
}
function handleExportCsv() {
if (!board) return;
const csv = exportBoardAsCsv(board);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(csv, `${safeName}.csv`, "text/csv");
}
function handleImportClick() { function handleImportClick() {
fileInputRef.current?.click(); fileInputRef.current?.click();
} }
@@ -77,9 +44,10 @@ export function ImportExportButtons() {
await openBoard(imported.id); await openBoard(imported.id);
setView({ type: "board", boardId: imported.id }); setView({ type: "board", boardId: imported.id });
addRecentBoard(imported.id); addRecentBoard(imported.id);
addToast("Board imported successfully", "success");
} catch (err) { } catch (err) {
console.error("Import failed:", err); console.error("Import failed:", err);
// Could show a toast here in the future addToast("Import failed — check file format", "error");
} }
// Reset the input so the same file can be re-imported // Reset the input so the same file can be re-imported
@@ -89,8 +57,7 @@ export function ImportExportButtons() {
} }
return ( return (
<div className="flex gap-2"> <>
{/* Import button */}
<Button variant="outline" size="sm" onClick={handleImportClick}> <Button variant="outline" size="sm" onClick={handleImportClick}>
<Upload className="size-4" /> <Upload className="size-4" />
Import Import
@@ -102,24 +69,6 @@ export function ImportExportButtons() {
onChange={handleFileSelected} onChange={handleFileSelected}
className="hidden" className="hidden"
/> />
</>
{/* Export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={!board}>
<Download className="size-4" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExportJson}>
Export as JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportCsv}>
Export as CSV
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
); );
} }

View File

@@ -1,13 +1,29 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { ArrowLeft, Settings, Search } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion";
import { springs } from "@/lib/motion";
import { ArrowLeft, Settings, Search, Undo2, Redo2, SlidersHorizontal, Filter } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { VersionHistoryDialog } from "@/components/board/VersionHistoryDialog";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { WindowControls } from "@/components/layout/WindowControls";
export function TopBar() { export function TopBar() {
const view = useAppStore((s) => s.view); const view = useAppStore((s) => s.view);
@@ -19,6 +35,7 @@ export function TopBar() {
const isBoardView = view.type === "board"; const isBoardView = view.type === "board";
const [showVersionHistory, setShowVersionHistory] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -65,17 +82,21 @@ export function TopBar() {
return ( return (
<header <header
data-tauri-drag-region data-tauri-drag-region
className="flex h-12 shrink-0 items-center gap-2 border-b border-border bg-pylon-surface px-3" className="flex h-12 shrink-0 items-center gap-2 bg-pylon-surface px-3"
style={{ borderBottom: isBoardView && board ? `2px solid ${board.color}` : '1px solid var(--border)' }}
> >
{/* Left section */} {/* Left section */}
<div className="flex items-center gap-2"> <div data-tauri-drag-region className="flex items-center gap-2">
{isBoardView && ( {isBoardView && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setView({ type: "board-list" })} onClick={() => {
useBoardStore.getState().closeBoard();
setView({ type: "board-list" });
}}
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
@@ -88,7 +109,7 @@ export function TopBar() {
</div> </div>
{/* Center section */} {/* Center section */}
<div className="flex flex-1 items-center justify-center"> <div data-tauri-drag-region className="flex flex-1 items-center justify-center select-none">
{isBoardView && board ? ( {isBoardView && board ? (
editing ? ( editing ? (
<input <input
@@ -102,13 +123,17 @@ export function TopBar() {
) : ( ) : (
<button <button
onClick={startEditing} onClick={startEditing}
className="rounded-md px-2 py-0.5 font-heading text-lg text-pylon-text hover:bg-pylon-column transition-colors" className="flex items-center gap-1.5 rounded-md px-2 py-0.5 font-heading text-lg text-pylon-text hover:bg-pylon-column transition-colors"
> >
<span
className="inline-block size-2.5 rounded-full shrink-0"
style={{ backgroundColor: board.color }}
/>
{board.title} {board.title}
</button> </button>
) )
) : ( ) : (
<span className="font-heading text-lg text-pylon-text"> <span className="pointer-events-none font-heading text-lg text-pylon-text">
OpenPylon OpenPylon
</span> </span>
)} )}
@@ -116,12 +141,116 @@ export function TopBar() {
{/* Right section */} {/* Right section */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isBoardView && (
<>
<AnimatePresence mode="wait">
{savingStatus && ( {savingStatus && (
<span className="mr-2 font-mono text-xs text-pylon-text-secondary"> <motion.span
key={savingStatus}
className="font-mono text-xs text-pylon-text-secondary"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={springs.snappy}
>
{savingStatus} {savingStatus}
</span> </motion.span>
)}
</AnimatePresence>
<div className="mx-2 h-4 w-px bg-pylon-text-secondary/20" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => useBoardStore.temporal.getState().undo()}
>
<Undo2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Undo <kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+Z</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => useBoardStore.temporal.getState().redo()}
>
<Redo2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Redo <kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+Shift+Z</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => document.dispatchEvent(new CustomEvent("toggle-filter-bar"))}
>
<Filter className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Filter cards <kbd className="ml-1 font-mono text-[10px] opacity-60">/</kbd>
</TooltipContent>
</Tooltip>
</>
)}
{isBoardView && board && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
>
<SlidersHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Background</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={board.settings.background}
onValueChange={(v) => useBoardStore.getState().updateBoardSettings({ ...board.settings, background: v as typeof board.settings.background })}
>
{(["none", "dots", "grid", "gradient"] as const).map((bg) => (
<DropdownMenuRadioItem key={bg} value={bg}>
{bg.charAt(0).toUpperCase() + bg.slice(1)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Attachments</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={board.settings.attachmentMode}
onValueChange={(v) => useBoardStore.getState().updateBoardSettings({ ...board.settings, attachmentMode: v as typeof board.settings.attachmentMode })}
>
<DropdownMenuRadioItem value="link">Link to original</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="copy">Copy into board</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowVersionHistory(true)}>
Version History
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)} )}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -156,7 +285,14 @@ export function TopBar() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Settings</TooltipContent> <TooltipContent>Settings</TooltipContent>
</Tooltip> </Tooltip>
<WindowControls />
</div> </div>
{isBoardView && (
<VersionHistoryDialog
open={showVersionHistory}
onOpenChange={setShowVersionHistory}
/>
)}
</header> </header>
); );
} }

View File

@@ -0,0 +1,81 @@
import { useState, useEffect, useCallback } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Minus, Square, Copy, X } from "lucide-react";
export function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
const appWindow = getCurrentWindow();
appWindow.isMaximized().then(setIsMaximized);
const unlisten = appWindow.onResized(async () => {
const maximized = await appWindow.isMaximized();
setIsMaximized(maximized);
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const handleMinimize = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
getCurrentWindow().minimize();
}, []);
const handleToggleMaximize = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
getCurrentWindow().toggleMaximize();
}, []);
const handleClose = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
getCurrentWindow().close();
}, []);
return (
<div className="flex items-center" onMouseDown={(e) => e.stopPropagation()}>
{/* Separator */}
<div className="mx-2 h-4 w-px bg-pylon-text-secondary/20" />
{/* Minimize */}
<button
onClick={handleMinimize}
onMouseDown={(e) => e.stopPropagation()}
className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-all hover:scale-110 hover:bg-pylon-accent/10 hover:text-pylon-text active:scale-90"
aria-label="Minimize"
>
<Minus className="size-4" />
</button>
{/* Maximize / Restore */}
<button
onClick={handleToggleMaximize}
onMouseDown={(e) => e.stopPropagation()}
className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-all hover:scale-110 hover:bg-pylon-accent/10 hover:text-pylon-text active:scale-90"
aria-label={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
<Copy className="size-3.5" />
) : (
<Square className="size-3.5" />
)}
</button>
{/* Close */}
<button
onClick={handleClose}
onMouseDown={(e) => e.stopPropagation()}
className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-all hover:scale-110 hover:bg-pylon-danger/15 hover:text-pylon-danger active:scale-90"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
);
}

View File

@@ -1,4 +1,9 @@
import { Sun, Moon, Monitor } from "lucide-react"; import { useState, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { springs, microInteraction } from "@/lib/motion";
import {
Sun, Moon, Monitor, RotateCcw,
} from "lucide-react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,12 +15,15 @@ import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import type { AppSettings } from "@/types/settings"; import type { AppSettings } from "@/types/settings";
import type { ColumnWidth } from "@/types/board";
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
type Tab = "appearance" | "boards" | "shortcuts" | "about";
const THEME_OPTIONS: { const THEME_OPTIONS: {
value: AppSettings["theme"]; value: AppSettings["theme"];
label: string; label: string;
@@ -26,13 +34,93 @@ const THEME_OPTIONS: {
{ value: "system", label: "System", icon: Monitor }, { value: "system", label: "System", icon: Monitor },
]; ];
const ACCENT_PRESETS: { hue: string; label: string }[] = [
{ hue: "160", label: "Teal" },
{ hue: "240", label: "Blue" },
{ hue: "300", label: "Purple" },
{ hue: "350", label: "Pink" },
{ hue: "25", label: "Red" },
{ hue: "55", label: "Orange" },
{ hue: "85", label: "Yellow" },
{ hue: "130", label: "Lime" },
{ hue: "200", label: "Cyan" },
{ hue: "0", label: "Slate" },
];
const DENSITY_OPTIONS: {
value: AppSettings["density"];
label: string;
}[] = [
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
{ value: "spacious", label: "Spacious" },
];
const WIDTH_OPTIONS: { value: ColumnWidth; label: string }[] = [
{ value: "narrow", label: "Narrow" },
{ value: "standard", label: "Standard" },
{ value: "wide", label: "Wide" },
];
const SHORTCUTS: { key: string; description: string; category: string }[] = [
{ key: "Ctrl+K", description: "Open command palette", category: "Navigation" },
{ key: "Ctrl+Z", description: "Undo", category: "Board" },
{ key: "Ctrl+Shift+Z", description: "Redo", category: "Board" },
{ key: "?", description: "Keyboard shortcuts", category: "Navigation" },
{ key: "Escape", description: "Close modal / cancel", category: "Navigation" },
];
const TABS: { value: Tab; label: string }[] = [
{ value: "appearance", label: "Appearance" },
{ value: "boards", label: "Boards" },
{ value: "shortcuts", label: "Shortcuts" },
{ value: "about", label: "About" },
];
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<label className="mb-2 block font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{children}
</label>
);
}
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const theme = useAppStore((s) => s.settings.theme); const [tab, setTab] = useState<Tab>("appearance");
const settings = useAppStore((s) => s.settings);
const setTheme = useAppStore((s) => s.setTheme); const setTheme = useAppStore((s) => s.setTheme);
const setAccentColor = useAppStore((s) => s.setAccentColor);
const setUiZoom = useAppStore((s) => s.setUiZoom);
const setDensity = useAppStore((s) => s.setDensity);
const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth);
const roRef = useRef<ResizeObserver | null>(null);
const [height, setHeight] = useState<number | "auto">("auto");
// Callback ref: sets up ResizeObserver when dialog content mounts in portal
const contentRef = useCallback((node: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
if (node) {
const measure = () => setHeight(node.getBoundingClientRect().height);
measure();
roRef.current = new ResizeObserver(measure);
roRef.current.observe(node);
}
}, []);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-md"> <DialogContent className="bg-pylon-surface sm:max-w-lg overflow-hidden p-0">
<motion.div
animate={{ height: typeof height === "number" && height > 0 ? height : "auto" }}
initial={false}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="overflow-hidden"
>
<div ref={contentRef} className="flex flex-col gap-4 p-6">
<DialogHeader> <DialogHeader>
<DialogTitle className="font-heading text-pylon-text"> <DialogTitle className="font-heading text-pylon-text">
Settings Settings
@@ -42,18 +130,42 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-5"> {/* Tab bar */}
{/* Theme section */} <div className="flex gap-1 border-b border-border pb-2">
{TABS.map((t) => (
<Button
key={t.value}
variant={tab === t.value ? "secondary" : "ghost"}
size="sm"
onClick={() => setTab(t.value)}
className="font-mono text-xs"
>
{t.label}
</Button>
))}
</div>
{/* Tab content — entire dialog height animates between tabs */}
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={tab}
className="flex flex-col gap-5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{tab === "appearance" && (
<>
{/* Theme */}
<div> <div>
<label className="mb-2 block font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"> <SectionLabel>Theme</SectionLabel>
Theme
</label>
<div className="flex gap-2"> <div className="flex gap-2">
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( {THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
<Button <Button
key={value} key={value}
type="button" type="button"
variant={theme === value ? "default" : "outline"} variant={settings.theme === value ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setTheme(value)} onClick={() => setTheme(value)}
className="flex-1 gap-2" className="flex-1 gap-2"
@@ -67,19 +179,146 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<Separator /> <Separator />
{/* About section */} {/* UI Zoom */}
<div> <div>
<label className="mb-2 block font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"> <div className="mb-2 flex items-center justify-between">
About <SectionLabel>UI Zoom</SectionLabel>
</label> <div className="flex items-center gap-2">
<div className="space-y-1 text-sm text-pylon-text"> <span className="font-mono text-xs text-pylon-text-secondary">
<p className="font-semibold">OpenPylon v0.1.0</p> {Math.round(settings.uiZoom * 100)}%
</span>
{settings.uiZoom !== 1 && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => setUiZoom(1)}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<RotateCcw className="size-3" />
</Button>
)}
</div>
</div>
<input
type="range"
min="0.75"
max="1.5"
step="0.05"
value={settings.uiZoom}
onChange={(e) => setUiZoom(parseFloat(e.target.value))}
className="w-full accent-pylon-accent"
/>
<div className="mt-1 flex justify-between font-mono text-[10px] text-pylon-text-secondary">
<span>75%</span>
<span>100%</span>
<span>150%</span>
</div>
</div>
<Separator />
{/* Accent Color */}
<div>
<SectionLabel>Accent Color</SectionLabel>
<div className="flex flex-wrap gap-2">
{ACCENT_PRESETS.map(({ hue, label }) => {
const isAchromatic = hue === "0";
const bg = isAchromatic
? "oklch(50% 0 0)"
: `oklch(55% 0.12 ${hue})`;
return (
<motion.button
key={hue}
type="button"
onClick={() => setAccentColor(hue)}
className="size-7 rounded-full"
style={{
backgroundColor: bg,
outline: settings.accentColor === hue ? "2px solid currentColor" : "none",
outlineOffset: "2px",
}}
whileHover={microInteraction.hover}
whileTap={microInteraction.tap}
transition={springs.snappy}
aria-label={label}
title={label}
/>
);
})}
</div>
</div>
<Separator />
{/* Density */}
<div>
<SectionLabel>Density</SectionLabel>
<div className="flex gap-2">
{DENSITY_OPTIONS.map(({ value, label }) => (
<Button
key={value}
type="button"
variant={settings.density === value ? "default" : "outline"}
size="sm"
onClick={() => setDensity(value)}
className="flex-1"
>
{label}
</Button>
))}
</div>
</div>
</>
)}
{tab === "boards" && (
<div>
<SectionLabel>Default Column Width</SectionLabel>
<div className="flex gap-2">
{WIDTH_OPTIONS.map(({ value, label }) => (
<Button
key={value}
type="button"
variant={settings.defaultColumnWidth === value ? "default" : "outline"}
size="sm"
onClick={() => setDefaultColumnWidth(value)}
className="flex-1"
>
{label}
</Button>
))}
</div>
</div>
)}
{tab === "shortcuts" && (
<div className="flex flex-col gap-1">
{SHORTCUTS.map(({ key, description }) => (
<div key={key} className="flex items-center justify-between py-1">
<span className="text-sm text-pylon-text">{description}</span>
<kbd className="rounded bg-pylon-column px-2 py-0.5 font-mono text-xs text-pylon-text-secondary">
{key}
</kbd>
</div>
))}
</div>
)}
{tab === "about" && (
<div className="space-y-2 text-sm text-pylon-text">
<p className="font-heading text-lg">OpenPylon</p>
<p className="text-pylon-text-secondary"> <p className="text-pylon-text-secondary">
Local-first Kanban board v0.1.0 &middot; Local-first Kanban board
</p>
<p className="text-pylon-text-secondary">
Built with Tauri, React, and TypeScript.
</p> </p>
</div> </div>
)}
</motion.div>
</AnimatePresence>
</div> </div>
</div> </motion.div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -0,0 +1,74 @@
import { motion } from "framer-motion";
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
interface ShortcutHelpModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const SHORTCUT_GROUPS = [
{
category: "Navigation",
shortcuts: [
{ key: "Ctrl+K", description: "Open command palette" },
{ key: "?", description: "Show keyboard shortcuts" },
{ key: "Escape", description: "Close modal / cancel" },
],
},
{
category: "Board",
shortcuts: [
{ key: "Ctrl+Z", description: "Undo" },
{ key: "Ctrl+Shift+Z", description: "Redo" },
],
},
];
export function ShortcutHelpModal({ open, onOpenChange }: ShortcutHelpModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Keyboard Shortcuts
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Quick reference for all keyboard shortcuts.
</DialogDescription>
</DialogHeader>
<motion.div
className="flex flex-col gap-4"
variants={staggerContainer(0.06)}
initial="hidden"
animate="visible"
>
{SHORTCUT_GROUPS.map((group) => (
<motion.div key={group.category} variants={fadeSlideUp} transition={springs.bouncy}>
<h4 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{group.category}
</h4>
<div className="flex flex-col gap-1">
{group.shortcuts.map(({ key, description }) => (
<div key={key} className="flex items-center justify-between py-1">
<span className="text-sm text-pylon-text">{description}</span>
<kbd className="rounded bg-pylon-column px-2 py-0.5 font-mono text-xs text-pylon-text-secondary">
{key}
</kbd>
</div>
))}
</div>
</motion.div>
))}
</motion.div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,32 @@
import { AnimatePresence, motion } from "framer-motion";
import { springs } from "@/lib/motion";
import { useToastStore } from "@/stores/toast-store";
const TYPE_STYLES = {
success: "bg-pylon-accent/10 text-pylon-accent border-pylon-accent/20",
error: "bg-pylon-danger/10 text-pylon-danger border-pylon-danger/20",
info: "bg-pylon-surface text-pylon-text border-border",
} as const;
export function ToastContainer() {
const toasts = useToastStore((s) => s.toasts);
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
<AnimatePresence>
{toasts.map((toast) => (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.9 }}
transition={springs.wobbly}
className={`pointer-events-auto rounded-lg border px-4 py-2 text-sm shadow-md ${TYPE_STYLES[toast.type]}`}
>
{toast.message}
</motion.div>
))}
</AnimatePresence>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium leading-none transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 [&_svg]:-translate-y-px outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -0,0 +1,90 @@
import { useState, useEffect, useCallback } from "react";
import type { Board } from "@/types/board";
export function useKeyboardNavigation(
board: Board | null,
onOpenCard: (cardId: string) => void
) {
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!board) return;
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
const key = e.key.toLowerCase();
const isNav = ["j", "k", "h", "l", "arrowdown", "arrowup", "arrowleft", "arrowright", "enter", "escape"].includes(key);
if (!isNav) return;
e.preventDefault();
if (key === "escape") {
setFocusedCardId(null);
return;
}
if (key === "enter" && focusedCardId) {
onOpenCard(focusedCardId);
return;
}
// Build navigation grid
const columns = board.columns.filter((c) => !c.collapsed && c.cardIds.length > 0);
if (columns.length === 0) return;
// Find current position
let colIdx = -1;
let cardIdx = -1;
if (focusedCardId) {
for (let ci = 0; ci < columns.length; ci++) {
const idx = columns[ci].cardIds.indexOf(focusedCardId);
if (idx !== -1) {
colIdx = ci;
cardIdx = idx;
break;
}
}
}
// If nothing focused, focus first card
if (colIdx === -1) {
setFocusedCardId(columns[0].cardIds[0]);
return;
}
if (key === "j" || key === "arrowdown") {
const col = columns[colIdx];
const next = Math.min(cardIdx + 1, col.cardIds.length - 1);
setFocusedCardId(col.cardIds[next]);
} else if (key === "k" || key === "arrowup") {
const col = columns[colIdx];
const next = Math.max(cardIdx - 1, 0);
setFocusedCardId(col.cardIds[next]);
} else if (key === "l" || key === "arrowright") {
const nextCol = Math.min(colIdx + 1, columns.length - 1);
const targetIdx = Math.min(cardIdx, columns[nextCol].cardIds.length - 1);
setFocusedCardId(columns[nextCol].cardIds[targetIdx]);
} else if (key === "h" || key === "arrowleft") {
const prevCol = Math.max(colIdx - 1, 0);
const targetIdx = Math.min(cardIdx, columns[prevCol].cardIds.length - 1);
setFocusedCardId(columns[prevCol].cardIds[targetIdx]);
}
},
[board, focusedCardId, onOpenCard]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
// Clear focus when a card is removed
useEffect(() => {
if (focusedCardId && board && !board.cards[focusedCardId]) {
setFocusedCardId(null);
}
}, [board, focusedCardId]);
return { focusedCardId, setFocusedCardId };
}

View File

@@ -17,14 +17,8 @@ export function useKeyboardShortcuts(): void {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
const ctrl = e.ctrlKey || e.metaKey; const ctrl = e.ctrlKey || e.metaKey;
// Ctrl+K: open command palette (always works, even in inputs)
if (ctrl && e.key === "k") {
e.preventDefault();
document.dispatchEvent(new CustomEvent("open-command-palette"));
return;
}
// Skip remaining shortcuts when an input is focused // Skip remaining shortcuts when an input is focused
// Note: Ctrl+K for command palette is handled directly by CommandPalette component
if (isInputFocused()) return; if (isInputFocused()) return;
// Ctrl+Shift+Z: redo // Ctrl+Shift+Z: redo
@@ -47,6 +41,12 @@ export function useKeyboardShortcuts(): void {
document.dispatchEvent(new CustomEvent("close-all-modals")); document.dispatchEvent(new CustomEvent("close-all-modals"));
return; return;
} }
if (e.key === "?" || (e.shiftKey && e.key === "/")) {
e.preventDefault();
document.dispatchEvent(new CustomEvent("open-shortcut-help"));
return;
}
} }
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);

View File

@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -51,12 +52,13 @@
--color-pylon-text-secondary: var(--pylon-text-secondary); --color-pylon-text-secondary: var(--pylon-text-secondary);
--color-pylon-danger: var(--pylon-danger); --color-pylon-danger: var(--pylon-danger);
--font-heading: "Instrument Serif", Georgia, serif; --font-heading: "Instrument Serif", Georgia, serif;
--font-body: "Satoshi", system-ui, -apple-system, sans-serif; --font-body: "Epilogue", system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", "JetBrains Mono", "Fira Code", monospace; --font-mono: "Space Mono", "Courier New", monospace;
} }
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--density-factor: 1;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
@@ -98,53 +100,93 @@
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.22 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.27 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.27 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.32 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.32 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.32 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 18%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.27 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
--pylon-bg: oklch(18% 0.01 50); --pylon-bg: oklch(25% 0.012 50);
--pylon-surface: oklch(22% 0.01 50); --pylon-surface: oklch(29% 0.012 50);
--pylon-column: oklch(20% 0.012 50); --pylon-column: oklch(27% 0.014 50);
--pylon-accent: oklch(60% 0.12 160); --pylon-accent: oklch(62% 0.13 160);
--pylon-text: oklch(90% 0.01 50); --pylon-text: oklch(92% 0.01 50);
--pylon-text-secondary: oklch(55% 0.01 50); --pylon-text-secondary: oklch(58% 0.01 50);
--pylon-danger: oklch(60% 0.18 25); --pylon-danger: oklch(62% 0.18 25);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: oklch(50% 0 0 / 20%) transparent;
}
.dark * {
scrollbar-color: oklch(80% 0 0 / 15%) transparent;
}
/* Hide native scrollbars — OverlayScrollbars renders custom ones */
::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Satoshi", system-ui, -apple-system, sans-serif; font-family: "Epilogue", system-ui, -apple-system, sans-serif;
}
:focus-visible {
outline: 2px solid var(--pylon-accent);
outline-offset: 2px;
}
}
/* OverlayScrollbars custom theme */
.os-theme-pylon {
--os-handle-bg: oklch(50% 0 0 / 22%);
--os-handle-bg-hover: oklch(50% 0 0 / 40%);
--os-handle-bg-active: oklch(50% 0 0 / 55%);
--os-size: 8px;
--os-handle-border-radius: 9999px;
--os-padding-perpendicular: 2px;
--os-padding-axis: 2px;
--os-handle-min-size: 30px;
}
.dark .os-theme-pylon {
--os-handle-bg: oklch(80% 0 0 / 18%);
--os-handle-bg-hover: oklch(80% 0 0 / 35%);
--os-handle-bg-active: oklch(80% 0 0 / 50%);
}
@media (prefers-contrast: more) {
:root {
--pylon-text: oklch(10% 0.02 50);
--pylon-text-secondary: oklch(35% 0.01 50);
} }
} }

View File

@@ -1,5 +1,6 @@
import { ulid } from "ulid"; import { ulid } from "ulid";
import type { Board, ColumnWidth } from "@/types/board"; import type { Board, ColumnWidth } from "@/types/board";
import type { BoardTemplate } from "@/types/template";
type Template = "blank" | "kanban" | "sprint"; type Template = "blank" | "kanban" | "sprint";
@@ -18,7 +19,7 @@ export function createBoard(
columns: [], columns: [],
cards: {}, cards: {},
labels: [], labels: [],
settings: { attachmentMode: "link" }, settings: { attachmentMode: "link" as const, background: "none" as const },
}; };
const col = (t: string, w: ColumnWidth = "standard") => ({ const col = (t: string, w: ColumnWidth = "standard") => ({
@@ -26,6 +27,9 @@ export function createBoard(
title: t, title: t,
cardIds: [] as string[], cardIds: [] as string[],
width: w, width: w,
color: null as string | null,
collapsed: false,
wipLimit: null as number | null,
}); });
if (template === "kanban") { if (template === "kanban") {
@@ -42,3 +46,26 @@ export function createBoard(
return board; return board;
} }
export function createBoardFromTemplate(template: BoardTemplate, title: string): Board {
const ts = new Date().toISOString();
return {
id: ulid(),
title,
color: template.color,
createdAt: ts,
updatedAt: ts,
columns: template.columns.map((c) => ({
id: ulid(),
title: c.title,
cardIds: [],
width: c.width,
color: c.color,
collapsed: false,
wipLimit: c.wipLimit,
})),
cards: {},
labels: template.labels.map((l) => ({ ...l, id: ulid() })),
settings: { ...template.settings },
};
}

View File

@@ -153,6 +153,9 @@ export function importFromTrelloJson(jsonString: string): Board {
title: list.name, title: list.name,
cardIds: [], cardIds: [],
width: "standard" as const, width: "standard" as const,
color: null,
collapsed: false,
wipLimit: null,
}; };
}); });
@@ -201,6 +204,9 @@ export function importFromTrelloJson(jsonString: string): Board {
checklist, checklist,
dueDate: tCard.due ?? null, dueDate: tCard.due ?? null,
attachments: [], attachments: [],
coverColor: null,
priority: "none",
comments: [],
createdAt: ts, createdAt: ts,
updatedAt: ts, updatedAt: ts,
}; };
@@ -223,7 +229,7 @@ export function importFromTrelloJson(jsonString: string): Board {
columns, columns,
cards, cards,
labels, labels,
settings: { attachmentMode: "link" }, settings: { attachmentMode: "link", background: "none" },
}; };
return board; return board;

67
src/lib/motion.ts Normal file
View File

@@ -0,0 +1,67 @@
import type { Transition, Variants } from "framer-motion";
// --- Spring presets ---
export const springs = {
bouncy: { type: "spring", stiffness: 400, damping: 15, mass: 0.8 } as Transition,
snappy: { type: "spring", stiffness: 500, damping: 20 } as Transition,
gentle: { type: "spring", stiffness: 200, damping: 20 } as Transition,
wobbly: { type: "spring", stiffness: 300, damping: 10 } as Transition,
};
// --- Reusable variants ---
export const fadeSlideUp: Variants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
};
export const fadeSlideDown: Variants = {
hidden: { opacity: 0, y: -12 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 8 },
};
export const fadeSlideLeft: Variants = {
hidden: { opacity: 0, x: 40 },
visible: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -40 },
};
export const fadeSlideRight: Variants = {
hidden: { opacity: 0, x: -40 },
visible: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 40 },
};
export const scaleIn: Variants = {
hidden: { opacity: 0, scale: 0.9 },
visible: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
};
// --- Stagger container ---
export function staggerContainer(staggerDelay = 0.04): Variants {
return {
hidden: {},
visible: {
transition: {
staggerChildren: staggerDelay,
},
},
};
}
// --- Micro-interaction presets ---
export const microInteraction = {
hover: { scale: 1.05 },
tap: { scale: 0.95 },
};
export const subtleHover = {
hover: { scale: 1.02 },
tap: { scale: 0.98 },
};

View File

@@ -6,6 +6,12 @@ export const checklistItemSchema = z.object({
checked: z.boolean(), checked: z.boolean(),
}); });
export const commentSchema = z.object({
id: z.string(),
text: z.string(),
createdAt: z.string(),
});
export const attachmentSchema = z.object({ export const attachmentSchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
@@ -27,6 +33,9 @@ export const cardSchema = z.object({
checklist: z.array(checklistItemSchema).default([]), checklist: z.array(checklistItemSchema).default([]),
dueDate: z.string().nullable().default(null), dueDate: z.string().nullable().default(null),
attachments: z.array(attachmentSchema).default([]), attachments: z.array(attachmentSchema).default([]),
coverColor: z.string().nullable().default(null),
priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
comments: z.array(commentSchema).default([]),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
}); });
@@ -36,10 +45,14 @@ export const columnSchema = z.object({
title: z.string(), title: z.string(),
cardIds: z.array(z.string()).default([]), cardIds: z.array(z.string()).default([]),
width: z.enum(["narrow", "standard", "wide"]).default("standard"), width: z.enum(["narrow", "standard", "wide"]).default("standard"),
color: z.string().nullable().default(null),
collapsed: z.boolean().default(false),
wipLimit: z.number().nullable().default(null),
}); });
export const boardSettingsSchema = z.object({ export const boardSettingsSchema = z.object({
attachmentMode: z.enum(["link", "copy"]).default("link"), attachmentMode: z.enum(["link", "copy"]).default("link"),
background: z.enum(["none", "dots", "grid", "gradient"]).default("none"),
}); });
export const boardSchema = z.object({ export const boardSchema = z.object({
@@ -51,11 +64,27 @@ export const boardSchema = z.object({
columns: z.array(columnSchema).default([]), columns: z.array(columnSchema).default([]),
cards: z.record(z.string(), cardSchema).default({}), cards: z.record(z.string(), cardSchema).default({}),
labels: z.array(labelSchema).default([]), labels: z.array(labelSchema).default([]),
settings: boardSettingsSchema.default({ attachmentMode: "link" }), settings: boardSettingsSchema.default({ attachmentMode: "link", background: "none" }),
});
export const windowStateSchema = z.object({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
maximized: z.boolean(),
}); });
export const appSettingsSchema = z.object({ export const appSettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]).default("system"), theme: z.enum(["light", "dark", "system"]).default("system"),
dataDirectory: z.string().nullable().default(null), dataDirectory: z.string().nullable().default(null),
recentBoardIds: z.array(z.string()).default([]), recentBoardIds: z.array(z.string()).default([]),
accentColor: z.string().default("160"),
uiZoom: z.number().min(0.75).max(1.5).default(1),
density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
defaultColumnWidth: z.enum(["narrow", "standard", "wide"]).default("standard"),
windowState: windowStateSchema.nullable().default(null),
boardSortOrder: z.enum(["manual", "title", "created", "updated"]).default("updated"),
boardManualOrder: z.array(z.string()).default([]),
lastNotificationCheck: z.string().nullable().default(null),
}); });

View File

@@ -7,18 +7,19 @@ import {
remove, remove,
copyFile, copyFile,
} from "@tauri-apps/plugin-fs"; } from "@tauri-apps/plugin-fs";
import { appDataDir, join } from "@tauri-apps/api/path"; import { join } from "@tauri-apps/api/path";
import { invoke } from "@tauri-apps/api/core";
import { boardSchema, appSettingsSchema } from "./schemas"; import { boardSchema, appSettingsSchema } from "./schemas";
import type { Board, BoardMeta } from "@/types/board"; import type { Board, BoardMeta } from "@/types/board";
import type { AppSettings } from "@/types/settings"; import type { AppSettings } from "@/types/settings";
import type { BoardTemplate } from "@/types/template";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path helpers // Path helpers — portable: all data lives next to the exe
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function getBaseDir(): Promise<string> { async function getBaseDir(): Promise<string> {
const base = await appDataDir(); return invoke<string>("get_portable_data_dir");
return join(base, "openpylon");
} }
async function getBoardsDir(): Promise<string> { async function getBoardsDir(): Promise<string> {
@@ -36,6 +37,16 @@ async function getSettingsPath(): Promise<string> {
return join(base, "settings.json"); return join(base, "settings.json");
} }
async function getTemplatesDir(): Promise<string> {
const base = await getBaseDir();
return join(base, "templates");
}
async function getBackupsDir(boardId: string): Promise<string> {
const base = await getBaseDir();
return join(base, "backups", boardId);
}
function boardFilePath(boardsDir: string, boardId: string): Promise<string> { function boardFilePath(boardsDir: string, boardId: string): Promise<string> {
return join(boardsDir, `${boardId}.json`); return join(boardsDir, `${boardId}.json`);
} }
@@ -63,6 +74,16 @@ export async function ensureDataDirs(): Promise<void> {
if (!(await exists(attachmentsDir))) { if (!(await exists(attachmentsDir))) {
await mkdir(attachmentsDir, { recursive: true }); await mkdir(attachmentsDir, { recursive: true });
} }
const templatesDir = await getTemplatesDir();
if (!(await exists(templatesDir))) {
await mkdir(templatesDir, { recursive: true });
}
const backupsDir = await join(base, "backups");
if (!(await exists(backupsDir))) {
await mkdir(backupsDir, { recursive: true });
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -124,6 +145,7 @@ export async function listBoards(): Promise<BoardMeta[]> {
color: board.color, color: board.color,
cardCount: Object.keys(board.cards).length, cardCount: Object.keys(board.cards).length,
columnCount: board.columns.length, columnCount: board.columns.length,
createdAt: board.createdAt,
updatedAt: board.updatedAt, updatedAt: board.updatedAt,
}); });
} catch { } catch {
@@ -160,6 +182,14 @@ export async function saveBoard(board: Board): Promise<void> {
try { try {
const previous = await readTextFile(filePath); const previous = await readTextFile(filePath);
await writeTextFile(backupPath, previous); await writeTextFile(backupPath, previous);
// Create timestamped backup (throttled: only if last backup > 5 min ago)
const backups = await listBackups(board.id);
const recentThreshold = new Date(Date.now() - 5 * 60 * 1000).toISOString();
if (backups.length === 0 || backups[0].timestamp < recentThreshold) {
await createBackup(JSON.parse(previous) as Board);
await pruneBackups(board.id);
}
} catch { } catch {
// If we can't create a backup, continue saving anyway // If we can't create a backup, continue saving anyway
} }
@@ -274,6 +304,112 @@ export async function searchAllBoards(query: string): Promise<SearchResult[]> {
// Attachment helpers // Attachment helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Templates
// ---------------------------------------------------------------------------
export async function listTemplates(): Promise<BoardTemplate[]> {
const dir = await getTemplatesDir();
if (!(await exists(dir))) return [];
const entries = await readDir(dir);
const templates: BoardTemplate[] = [];
for (const entry of entries) {
if (!entry.name || !entry.name.endsWith(".json")) continue;
try {
const filePath = await join(dir, entry.name);
const raw = await readTextFile(filePath);
templates.push(JSON.parse(raw));
} catch { continue; }
}
return templates;
}
export async function saveTemplate(template: BoardTemplate): Promise<void> {
const dir = await getTemplatesDir();
const filePath = await join(dir, `${template.id}.json`);
await writeTextFile(filePath, JSON.stringify(template, null, 2));
}
export async function deleteTemplate(templateId: string): Promise<void> {
const dir = await getTemplatesDir();
const filePath = await join(dir, `${templateId}.json`);
if (await exists(filePath)) {
await remove(filePath);
}
}
// ---------------------------------------------------------------------------
// Backups / Version History
// ---------------------------------------------------------------------------
export interface BackupEntry {
filename: string;
timestamp: string;
cardCount: number;
columnCount: number;
}
export async function listBackups(boardId: string): Promise<BackupEntry[]> {
const dir = await getBackupsDir(boardId);
if (!(await exists(dir))) return [];
const entries = await readDir(dir);
const backups: BackupEntry[] = [];
for (const entry of entries) {
if (!entry.name || !entry.name.endsWith(".json")) continue;
try {
const filePath = await join(dir, entry.name);
const raw = await readTextFile(filePath);
const data = JSON.parse(raw);
const board = boardSchema.parse(data);
const isoMatch = entry.name.match(/\d{4}-\d{2}-\d{2}T[\d.]+Z/);
backups.push({
filename: entry.name,
timestamp: isoMatch ? isoMatch[0].replace(/-(?=\d{2}-\d{2}T)/g, "-") : board.updatedAt,
cardCount: Object.keys(board.cards).length,
columnCount: board.columns.length,
});
} catch { continue; }
}
backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
return backups;
}
export async function createBackup(board: Board): Promise<void> {
const dir = await getBackupsDir(board.id);
if (!(await exists(dir))) {
await mkdir(dir, { recursive: true });
}
const ts = new Date().toISOString().replace(/:/g, "-");
const filename = `${board.id}-${ts}.json`;
const filePath = await join(dir, filename);
await writeTextFile(filePath, JSON.stringify(board, null, 2));
}
export async function pruneBackups(boardId: string, keep: number = 10): Promise<void> {
const backups = await listBackups(boardId);
if (backups.length <= keep) return;
const dir = await getBackupsDir(boardId);
const toDelete = backups.slice(keep);
for (const backup of toDelete) {
try {
const filePath = await join(dir, backup.filename);
await remove(filePath);
} catch { /* skip */ }
}
}
export async function restoreBackupFile(boardId: string, filename: string): Promise<Board> {
const dir = await getBackupsDir(boardId);
const filePath = await join(dir, filename);
const raw = await readTextFile(filePath);
const data = JSON.parse(raw);
return boardSchema.parse(data) as Board;
}
// ---------------------------------------------------------------------------
// Attachment helpers
// ---------------------------------------------------------------------------
export async function copyAttachment( export async function copyAttachment(
boardId: string, boardId: string,
sourcePath: string, sourcePath: string,

View File

@@ -1,8 +1,12 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { OverlayScrollbars, ClickScrollPlugin } from "overlayscrollbars";
import App from "./App"; import App from "./App";
import "overlayscrollbars/overlayscrollbars.css";
import "./index.css"; import "./index.css";
OverlayScrollbars.plugin(ClickScrollPlugin);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />

View File

@@ -1,7 +1,8 @@
import { create } from "zustand"; import { create } from "zustand";
import type { AppSettings } from "@/types/settings"; import type { AppSettings, BoardSortOrder } from "@/types/settings";
import type { BoardMeta } from "@/types/board"; import type { BoardMeta, ColumnWidth } from "@/types/board";
import { loadSettings, saveSettings, listBoards, ensureDataDirs } from "@/lib/storage"; import { loadSettings, saveSettings, listBoards, ensureDataDirs, loadBoard } from "@/lib/storage";
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
export type View = { type: "board-list" } | { type: "board"; boardId: string }; export type View = { type: "board-list" } | { type: "board"; boardId: string };
@@ -13,9 +14,16 @@ interface AppState {
init: () => Promise<void>; init: () => Promise<void>;
setTheme: (theme: AppSettings["theme"]) => void; setTheme: (theme: AppSettings["theme"]) => void;
setAccentColor: (hue: string) => void;
setUiZoom: (zoom: number) => void;
setDensity: (density: AppSettings["density"]) => void;
setDefaultColumnWidth: (width: ColumnWidth) => void;
setView: (view: View) => void; setView: (view: View) => void;
refreshBoards: () => Promise<void>; refreshBoards: () => Promise<void>;
addRecentBoard: (boardId: string) => void; addRecentBoard: (boardId: string) => void;
setBoardSortOrder: (order: BoardSortOrder) => void;
setBoardManualOrder: (ids: string[]) => void;
getSortedBoards: () => BoardMeta[];
} }
function applyTheme(theme: AppSettings["theme"]): void { function applyTheme(theme: AppSettings["theme"]): void {
@@ -28,8 +36,41 @@ function applyTheme(theme: AppSettings["theme"]): void {
} }
} }
function applyAppearance(settings: AppSettings): void {
const root = document.documentElement;
root.style.fontSize = `${settings.uiZoom * 16}px`;
const hue = settings.accentColor;
const isDark = root.classList.contains("dark");
const lightness = isDark ? "60%" : "55%";
root.style.setProperty("--pylon-accent", `oklch(${lightness} 0.12 ${hue})`);
const densityMap = { compact: "0.75", comfortable: "1", spacious: "1.25" };
root.style.setProperty("--density-factor", densityMap[settings.density]);
}
function updateAndSave(
get: () => AppState,
set: (partial: Partial<AppState>) => void,
patch: Partial<AppSettings>
): void {
const settings = { ...get().settings, ...patch };
set({ settings });
saveSettings(settings);
}
export const useAppStore = create<AppState>((set, get) => ({ export const useAppStore = create<AppState>((set, get) => ({
settings: { theme: "system", dataDirectory: null, recentBoardIds: [] }, settings: {
theme: "system",
dataDirectory: null,
recentBoardIds: [],
accentColor: "160",
uiZoom: 1,
density: "comfortable",
defaultColumnWidth: "standard",
windowState: null,
boardSortOrder: "updated",
boardManualOrder: [],
lastNotificationCheck: null,
},
boards: [], boards: [],
view: { type: "board-list" }, view: { type: "board-list" },
initialized: false, initialized: false,
@@ -40,13 +81,71 @@ export const useAppStore = create<AppState>((set, get) => ({
const boards = await listBoards(); const boards = await listBoards();
set({ settings, boards, initialized: true }); set({ settings, boards, initialized: true });
applyTheme(settings.theme); applyTheme(settings.theme);
applyAppearance(settings);
// Due date notifications (once per hour)
const lastCheck = settings.lastNotificationCheck;
const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
if (!lastCheck || lastCheck < hourAgo) {
try {
let granted = await isPermissionGranted();
if (!granted) {
const perm = await requestPermission();
granted = perm === "granted";
}
if (granted) {
let dueToday = 0;
let overdue = 0;
const today = new Date();
const todayStr = today.toDateString();
for (const meta of boards) {
try {
const board = await loadBoard(meta.id);
for (const card of Object.values(board.cards)) {
if (!card.dueDate) continue;
const due = new Date(card.dueDate);
if (due.toDateString() === todayStr) dueToday++;
else if (due < today) overdue++;
}
} catch { /* skip */ }
}
if (dueToday > 0) {
sendNotification({ title: "OpenPylon", body: `You have ${dueToday} card${dueToday > 1 ? "s" : ""} due today` });
}
if (overdue > 0) {
sendNotification({ title: "OpenPylon", body: `You have ${overdue} overdue card${overdue > 1 ? "s" : ""}` });
}
}
updateAndSave(get, set, { lastNotificationCheck: new Date().toISOString() });
} catch { /* notification plugin not available */ }
}
}, },
setTheme: (theme) => { setTheme: (theme) => {
const settings = { ...get().settings, theme }; updateAndSave(get, set, { theme });
set({ settings });
saveSettings(settings);
applyTheme(theme); applyTheme(theme);
applyAppearance({ ...get().settings, theme });
},
setAccentColor: (accentColor) => {
updateAndSave(get, set, { accentColor });
applyAppearance(get().settings);
},
setUiZoom: (uiZoom) => {
updateAndSave(get, set, { uiZoom });
applyAppearance(get().settings);
},
setDensity: (density) => {
updateAndSave(get, set, { density });
applyAppearance(get().settings);
},
setDefaultColumnWidth: (defaultColumnWidth) => {
updateAndSave(get, set, { defaultColumnWidth });
}, },
setView: (view) => set({ view }), setView: (view) => set({ view }),
@@ -62,8 +161,46 @@ export const useAppStore = create<AppState>((set, get) => ({
boardId, boardId,
...settings.recentBoardIds.filter((id) => id !== boardId), ...settings.recentBoardIds.filter((id) => id !== boardId),
].slice(0, 10); ].slice(0, 10);
const updated = { ...settings, recentBoardIds: recent }; updateAndSave(get, set, { recentBoardIds: recent });
set({ settings: updated }); },
saveSettings(updated);
setBoardSortOrder: (boardSortOrder) => {
// When switching to manual for the first time, snapshot current order
if (boardSortOrder === "manual" && get().settings.boardManualOrder.length === 0) {
const currentSorted = get().getSortedBoards();
updateAndSave(get, set, {
boardSortOrder,
boardManualOrder: currentSorted.map((b) => b.id),
});
} else {
updateAndSave(get, set, { boardSortOrder });
}
},
setBoardManualOrder: (boardManualOrder) => {
updateAndSave(get, set, { boardManualOrder });
},
getSortedBoards: () => {
const { boards, settings } = get();
const order = settings.boardSortOrder;
if (order === "manual") {
const manualOrder = settings.boardManualOrder;
const orderMap = new Map(manualOrder.map((id, i) => [id, i]));
return [...boards].sort((a, b) => {
const ai = orderMap.get(a.id) ?? Infinity;
const bi = orderMap.get(b.id) ?? Infinity;
return ai - bi;
});
}
if (order === "title") {
return [...boards].sort((a, b) => a.title.localeCompare(b.title));
}
if (order === "created") {
return [...boards].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
// "updated" — default, already sorted from listBoards
return boards;
}, },
})); }));

View File

@@ -10,6 +10,7 @@ import type {
ColumnWidth, ColumnWidth,
} from "@/types/board"; } from "@/types/board";
import { saveBoard, loadBoard } from "@/lib/storage"; import { saveBoard, loadBoard } from "@/lib/storage";
import { useAppStore } from "@/stores/app-store";
interface BoardState { interface BoardState {
board: Board | null; board: Board | null;
@@ -26,10 +27,14 @@ interface BoardActions {
deleteColumn: (columnId: string) => void; deleteColumn: (columnId: string) => void;
moveColumn: (fromIndex: number, toIndex: number) => void; moveColumn: (fromIndex: number, toIndex: number) => void;
setColumnWidth: (columnId: string, width: ColumnWidth) => void; setColumnWidth: (columnId: string, width: ColumnWidth) => void;
setColumnColor: (columnId: string, color: string | null) => void;
setColumnWipLimit: (columnId: string, limit: number | null) => void;
toggleColumnCollapse: (columnId: string) => void;
addCard: (columnId: string, title: string) => string; addCard: (columnId: string, title: string) => string;
updateCard: (cardId: string, updates: Partial<Card>) => void; updateCard: (cardId: string, updates: Partial<Card>) => void;
deleteCard: (cardId: string) => void; deleteCard: (cardId: string) => void;
duplicateCard: (cardId: string) => string | null;
moveCard: ( moveCard: (
cardId: string, cardId: string,
fromColumnId: string, fromColumnId: string,
@@ -46,10 +51,14 @@ interface BoardActions {
toggleChecklistItem: (cardId: string, itemId: string) => void; toggleChecklistItem: (cardId: string, itemId: string) => void;
updateChecklistItem: (cardId: string, itemId: string, text: string) => void; updateChecklistItem: (cardId: string, itemId: string, text: string) => void;
deleteChecklistItem: (cardId: string, itemId: string) => void; deleteChecklistItem: (cardId: string, itemId: string) => void;
reorderChecklistItems: (cardId: string, fromIndex: number, toIndex: number) => void;
addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void; addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void;
removeAttachment: (cardId: string, attachmentId: string) => void; removeAttachment: (cardId: string, attachmentId: string) => void;
addComment: (cardId: string, text: string) => void;
deleteComment: (cardId: string, commentId: string) => void;
updateBoardTitle: (title: string) => void; updateBoardTitle: (title: string) => void;
updateBoardColor: (color: string) => void; updateBoardColor: (color: string) => void;
updateBoardSettings: (settings: Board["settings"]) => void; updateBoardSettings: (settings: Board["settings"]) => void;
@@ -63,6 +72,7 @@ function now(): string {
function debouncedSave( function debouncedSave(
board: Board, board: Board,
get: () => BoardState & BoardActions,
set: (partial: Partial<BoardState>) => void set: (partial: Partial<BoardState>) => void
): void { ): void {
if (saveTimeout) clearTimeout(saveTimeout); if (saveTimeout) clearTimeout(saveTimeout);
@@ -70,10 +80,15 @@ function debouncedSave(
set({ saving: true }); set({ saving: true });
try { try {
await saveBoard(board); await saveBoard(board);
// Only update state if the same board is still loaded
if (get().board?.id === board.id) {
set({ saving: false, lastSaved: Date.now() }); set({ saving: false, lastSaved: Date.now() });
}
} catch { } catch {
if (get().board?.id === board.id) {
set({ saving: false }); set({ saving: false });
} }
}
}, 500); }, 500);
} }
@@ -86,7 +101,7 @@ function mutate(
if (!board) return; if (!board) return;
const updated = updater(board); const updated = updater(board);
set({ board: updated }); set({ board: updated });
debouncedSave(updated, set); debouncedSave(updated, get, set);
} }
export const useBoardStore = create<BoardState & BoardActions>()( export const useBoardStore = create<BoardState & BoardActions>()(
@@ -114,6 +129,7 @@ export const useBoardStore = create<BoardState & BoardActions>()(
// -- Column actions -- // -- Column actions --
addColumn: (title: string) => { addColumn: (title: string) => {
const defaultWidth = useAppStore.getState().settings.defaultColumnWidth;
mutate(get, set, (b) => ({ mutate(get, set, (b) => ({
...b, ...b,
updatedAt: now(), updatedAt: now(),
@@ -123,7 +139,10 @@ export const useBoardStore = create<BoardState & BoardActions>()(
id: ulid(), id: ulid(),
title, title,
cardIds: [], cardIds: [],
width: "standard" as ColumnWidth, width: defaultWidth,
color: null,
collapsed: false,
wipLimit: null,
}, },
], ],
})); }));
@@ -166,12 +185,43 @@ export const useBoardStore = create<BoardState & BoardActions>()(
setColumnWidth: (columnId, width) => { setColumnWidth: (columnId, width) => {
mutate(get, set, (b) => ({ mutate(get, set, (b) => ({
...b, ...b,
updatedAt: now(),
columns: b.columns.map((c) => columns: b.columns.map((c) =>
c.id === columnId ? { ...c, width } : c c.id === columnId ? { ...c, width } : c
), ),
})); }));
}, },
setColumnColor: (columnId, color) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, color } : c
),
}));
},
setColumnWipLimit: (columnId, limit) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, wipLimit: limit } : c
),
}));
},
toggleColumnCollapse: (columnId) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, collapsed: !c.collapsed } : c
),
}));
},
// -- Card actions -- // -- Card actions --
addCard: (columnId, title) => { addCard: (columnId, title) => {
@@ -184,6 +234,9 @@ export const useBoardStore = create<BoardState & BoardActions>()(
checklist: [], checklist: [],
dueDate: null, dueDate: null,
attachments: [], attachments: [],
coverColor: null,
priority: "none",
comments: [],
createdAt: now(), createdAt: now(),
updatedAt: now(), updatedAt: now(),
}; };
@@ -234,6 +287,48 @@ export const useBoardStore = create<BoardState & BoardActions>()(
}); });
}, },
duplicateCard: (cardId) => {
const { board } = get();
if (!board) return null;
const original = board.cards[cardId];
if (!original) return null;
const column = board.columns.find((c) => c.cardIds.includes(cardId));
if (!column) return null;
const newId = ulid();
const ts = now();
const clone: Card = {
...original,
id: newId,
title: `${original.title} (copy)`,
comments: [],
createdAt: ts,
updatedAt: ts,
};
const insertIndex = column.cardIds.indexOf(cardId) + 1;
mutate(get, set, (b) => ({
...b,
updatedAt: ts,
cards: { ...b.cards, [newId]: clone },
columns: b.columns.map((c) =>
c.id === column.id
? {
...c,
cardIds: [
...c.cardIds.slice(0, insertIndex),
newId,
...c.cardIds.slice(insertIndex),
],
}
: c
),
}));
return newId;
},
moveCard: (cardId, fromColumnId, toColumnId, toIndex) => { moveCard: (cardId, fromColumnId, toColumnId, toIndex) => {
mutate(get, set, (b) => ({ mutate(get, set, (b) => ({
...b, ...b,
@@ -402,6 +497,24 @@ export const useBoardStore = create<BoardState & BoardActions>()(
}); });
}, },
reorderChecklistItems: (cardId, fromIndex, toIndex) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
const items = [...card.checklist];
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: { ...card, checklist: items, updatedAt: now() },
},
};
});
},
// -- Attachment actions -- // -- Attachment actions --
addAttachment: (cardId, attachment) => { addAttachment: (cardId, attachment) => {
@@ -447,6 +560,47 @@ export const useBoardStore = create<BoardState & BoardActions>()(
}); });
}, },
// -- Comment actions --
addComment: (cardId, text) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
const comment = { id: ulid(), text, createdAt: now() };
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
comments: [comment, ...card.comments],
updatedAt: now(),
},
},
};
});
},
deleteComment: (cardId, commentId) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
comments: card.comments.filter((c) => c.id !== commentId),
updatedAt: now(),
},
},
};
});
},
// -- Board metadata -- // -- Board metadata --
updateBoardTitle: (title) => { updateBoardTitle: (title) => {

33
src/stores/toast-store.ts Normal file
View File

@@ -0,0 +1,33 @@
import { create } from "zustand";
export type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastState {
toasts: Toast[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: string) => void;
}
let nextId = 0;
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (message, type = "info") => {
const id = String(++nextId);
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
setTimeout(() => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
}, 3000);
},
removeToast: (id) => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
},
}));

View File

@@ -15,10 +15,15 @@ export interface Column {
title: string; title: string;
cardIds: string[]; cardIds: string[];
width: ColumnWidth; width: ColumnWidth;
color: string | null;
collapsed: boolean;
wipLimit: number | null;
} }
export type ColumnWidth = "narrow" | "standard" | "wide"; export type ColumnWidth = "narrow" | "standard" | "wide";
export type Priority = "none" | "low" | "medium" | "high" | "urgent";
export interface Card { export interface Card {
id: string; id: string;
title: string; title: string;
@@ -27,6 +32,9 @@ export interface Card {
checklist: ChecklistItem[]; checklist: ChecklistItem[];
dueDate: string | null; dueDate: string | null;
attachments: Attachment[]; attachments: Attachment[];
coverColor: string | null;
priority: Priority;
comments: Comment[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -43,6 +51,12 @@ export interface ChecklistItem {
checked: boolean; checked: boolean;
} }
export interface Comment {
id: string;
text: string;
createdAt: string;
}
export interface Attachment { export interface Attachment {
id: string; id: string;
name: string; name: string;
@@ -52,6 +66,7 @@ export interface Attachment {
export interface BoardSettings { export interface BoardSettings {
attachmentMode: "link" | "copy"; attachmentMode: "link" | "copy";
background: "none" | "dots" | "grid" | "gradient";
} }
export interface BoardMeta { export interface BoardMeta {
@@ -60,5 +75,6 @@ export interface BoardMeta {
color: string; color: string;
cardCount: number; cardCount: number;
columnCount: number; columnCount: number;
createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -1,5 +1,25 @@
import type { ColumnWidth } from "./board";
export interface WindowState {
x: number;
y: number;
width: number;
height: number;
maximized: boolean;
}
export type BoardSortOrder = "manual" | "title" | "created" | "updated";
export interface AppSettings { export interface AppSettings {
theme: "light" | "dark" | "system"; theme: "light" | "dark" | "system";
dataDirectory: string | null; dataDirectory: string | null;
recentBoardIds: string[]; recentBoardIds: string[];
accentColor: string;
uiZoom: number;
density: "compact" | "comfortable" | "spacious";
defaultColumnWidth: ColumnWidth;
windowState: WindowState | null;
boardSortOrder: BoardSortOrder;
boardManualOrder: string[];
lastNotificationCheck: string | null;
} }

15
src/types/template.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { ColumnWidth, Label, BoardSettings } from "./board";
export interface BoardTemplate {
id: string;
name: string;
color: string;
columns: {
title: string;
width: ColumnWidth;
color: string | null;
wipLimit: number | null;
}[];
labels: Label[];
settings: BoardSettings;
}