Compare commits
52 Commits
69ab00ca64
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cc3d55cbf | ||
|
|
d244bd1f19 | ||
|
|
fd0fe2cdcc | ||
|
|
59aaa688bb | ||
|
|
414c1f7d68 | ||
|
|
8dedbf6032 | ||
|
|
2e2740427e | ||
|
|
6340beb5d0 | ||
|
|
e535177914 | ||
|
|
8ca3b81e92 | ||
|
|
02ef3acbfe | ||
|
|
c6fea186ef | ||
|
|
bc12b5569a | ||
|
|
03e0b132da | ||
|
|
9365d16452 | ||
|
|
21302bdfe9 | ||
|
|
63b7de0e6f | ||
|
|
85c54a3768 | ||
|
|
24219bb212 | ||
|
|
feccc4e17a | ||
|
|
1e14edda6c | ||
|
|
3a3c3bd2e1 | ||
|
|
1ecf04efcf | ||
|
|
3d8bbc9ebb | ||
|
|
6f5415c9f5 | ||
|
|
275b663934 | ||
|
|
3703857ccf | ||
|
|
1e487e95a1 | ||
|
|
46c0df3ab8 | ||
|
|
e4edc201b9 | ||
|
|
fffa565423 | ||
|
|
e5e9483a8e | ||
|
|
fde10d1a3c | ||
|
|
6341897487 | ||
|
|
61a5f11f25 | ||
|
|
0ef77bc470 | ||
|
|
1556529307 | ||
|
|
f303d61677 | ||
|
|
c2928afb11 | ||
|
|
a7c9c83bb0 | ||
|
|
aa91ef35c8 | ||
|
|
c08efb5171 | ||
|
|
03a089efda | ||
|
|
a9d1ae3a28 | ||
|
|
1d99473a22 | ||
|
|
fceaf60462 | ||
|
|
557cf461db | ||
|
|
fa52a28749 | ||
|
|
c590146be0 | ||
|
|
2a81849c8d | ||
|
|
07a4275e8c | ||
|
|
4638ce046c |
116
LICENSE
Normal file
116
LICENSE
Normal 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
355
README.md
Normal 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>
|
||||||
73
docs/plans/2026-02-15-card-detail-redesign-design.md
Normal file
73
docs/plans/2026-02-15-card-detail-redesign-design.md
Normal 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
|
||||||
467
docs/plans/2026-02-15-card-detail-redesign-implementation.md
Normal file
467
docs/plans/2026-02-15-card-detail-redesign-implementation.md
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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"
|
||||||
|
```
|
||||||
87
docs/plans/2026-02-15-custom-date-picker-design.md
Normal file
87
docs/plans/2026-02-15-custom-date-picker-design.md
Normal 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)
|
||||||
456
docs/plans/2026-02-15-custom-date-picker-implementation.md
Normal file
456
docs/plans/2026-02-15-custom-date-picker-implementation.md
Normal 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"
|
||||||
|
```
|
||||||
188
docs/plans/2026-02-15-motion-darkmode-titlebar-design.md
Normal file
188
docs/plans/2026-02-15-motion-darkmode-titlebar-design.md
Normal 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
|
||||||
1273
docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md
Normal file
1273
docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
235
docs/plans/2026-02-15-visual-glow-up-design.md
Normal file
235
docs/plans/2026-02-15-visual-glow-up-design.md
Normal 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
|
||||||
1766
docs/plans/2026-02-15-visual-glow-up-implementation.md
Normal file
1766
docs/plans/2026-02-15-visual-glow-up-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
296
docs/plans/2026-02-16-15-improvements-design.md
Normal file
296
docs/plans/2026-02-16-15-improvements-design.md
Normal 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.
|
||||||
2474
docs/plans/2026-02-16-15-improvements-implementation.md
Normal file
2474
docs/plans/2026-02-16-15-improvements-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
57
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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
5543
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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/**" }]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
113
src/App.tsx
113
src/App.tsx
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/components/board/FilterBar.tsx
Normal file
146
src/components/board/FilterBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/components/board/VersionHistoryDialog.tsx
Normal file
119
src/components/board/VersionHistoryDialog.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"> </h3>
|
||||||
|
<p className="font-mono text-xs"> </p>
|
||||||
|
<p className="font-mono text-xs"> </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"
|
||||||
|
|||||||
34
src/components/boards/BoardCardOverlay.tsx
Normal file
34
src/components/boards/BoardCardOverlay.tsx
Normal 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" : ""} ·{" "}
|
||||||
|
{board.columnCount} column{board.columnCount !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-xs text-pylon-text-secondary">
|
||||||
|
{relativeTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
280
src/components/card-detail/CalendarPopover.tsx
Normal file
280
src/components/card-detail/CalendarPopover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
97
src/components/card-detail/CommentsSection.tsx
Normal file
97
src/components/card-detail/CommentsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
46
src/components/card-detail/PriorityPicker.tsx
Normal file
46
src/components/card-detail/PriorityPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/components/layout/WindowControls.tsx
Normal file
81
src/components/layout/WindowControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 · 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>
|
||||||
);
|
);
|
||||||
|
|||||||
74
src/components/shortcuts/ShortcutHelpModal.tsx
Normal file
74
src/components/shortcuts/ShortcutHelpModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/toast/ToastContainer.tsx
Normal file
32
src/components/toast/ToastContainer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
90
src/hooks/useKeyboardNavigation.ts
Normal file
90
src/hooks/useKeyboardNavigation.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
67
src/lib/motion.ts
Normal 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 },
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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
33
src/stores/toast-store.ts
Normal 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) }));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
15
src/types/template.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user