Compare commits
71 Commits
v1.1.0
...
69ab00ca64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69ab00ca64 | ||
|
|
64c42d092a | ||
|
|
799e3fe4b9 | ||
|
|
3f86bbd36d | ||
|
|
06a2f3ff3d | ||
|
|
f79e4c7090 | ||
|
|
fe5baec172 | ||
|
|
3918a66ff6 | ||
|
|
07034c6a25 | ||
|
|
a9fbaa2c0d | ||
|
|
739a9cee99 | ||
|
|
19f794bc45 | ||
|
|
b1e0114483 | ||
|
|
12afba9206 | ||
|
|
6f0d8c5f28 | ||
|
|
36dbcf6f69 | ||
|
|
93587d681a | ||
|
|
fe8178571d | ||
|
|
6258c7758d | ||
|
|
d07ced3682 | ||
|
|
d25a477a5d | ||
|
|
ee8a12827c | ||
|
|
db5d983ac3 | ||
|
|
bb1b6312ba | ||
|
|
6bbf4c973b | ||
|
|
7277bbdc21 | ||
|
|
fc4310a30f | ||
|
|
a17c8b6b62 | ||
|
|
b51818ada3 | ||
|
|
2ac59fa202 | ||
|
|
12c8209042 | ||
|
|
9db76881bd | ||
|
|
f4c21970ba | ||
|
|
f5c6e2e2a5 | ||
|
|
86b833a11b | ||
|
|
08fbeaa1b2 | ||
|
|
a226eabba4 | ||
|
|
436f8fecb9 | ||
|
|
11ad213a1d | ||
|
|
03a22d4e6a | ||
|
|
767bf4714b | ||
|
|
0c5a23ad9b | ||
|
|
af529a2d99 | ||
|
|
4b70afae5f | ||
|
|
895a31da9f | ||
|
|
940c10336e | ||
|
|
14c4e82070 | ||
|
|
d1a10ae8ff | ||
|
|
37fd56b43f | ||
|
|
9ae4bb5395 | ||
|
|
a1deae2650 | ||
|
|
43858357fe | ||
|
|
2f62dbba7c | ||
|
|
1547ad5a70 | ||
|
|
98d746ff4e | ||
|
|
2cddb7aa8f | ||
|
|
16ea05cfe0 | ||
|
|
27246d70f2 | ||
|
|
1353ccb720 | ||
|
|
683f14f2ae | ||
|
|
62ccb07fec | ||
|
|
afeebe2381 | ||
|
|
d6d0b8731b | ||
|
|
1592264514 | ||
|
|
ac43055a93 | ||
|
|
1a39d3bd31 | ||
|
|
11559e1435 | ||
|
|
d2adc68262 | ||
|
|
1da5f9834b | ||
|
|
083c351ab2 | ||
|
|
943b24c371 |
24
.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
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/>
|
||||||
402
README.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
<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.1.0-green" alt="Version 1.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
|
||||||
|
|
||||||
|
### ♿ Accessibility (WCAG 2.2 AAA)
|
||||||
|
|
||||||
|
OpenPylon targets WCAG 2.2 AAA conformance - because productivity tools should work for everyone, not just people with perfect vision and a mouse.
|
||||||
|
|
||||||
|
**Color and Contrast**
|
||||||
|
|
||||||
|
- **7:1 enhanced contrast** on all text and interactive elements, in both light and dark themes
|
||||||
|
- **3:1 non-text contrast** on borders, scrollbar thumbs, and focus indicators
|
||||||
|
- **High-contrast mode** support - `prefers-contrast: more` boosts all tokens further
|
||||||
|
- **Color is never the sole indicator** - priority levels, due date status, and labels all include text or shape cues alongside color
|
||||||
|
|
||||||
|
**Focus and Keyboard**
|
||||||
|
|
||||||
|
- **3px dual-ring focus indicators** visible on every interactive element, against any background
|
||||||
|
- **Skip-to-content link** as the first focusable element on the page
|
||||||
|
- **Full keyboard navigation** - vim keys, arrow keys, tab order, Escape to dismiss
|
||||||
|
- **Shift+F10 context menus** - right-click menus are also reachable via keyboard
|
||||||
|
- **Focus trapping** in all modals and dialogs with focus restore on close
|
||||||
|
- **Hidden interactive elements** (menu buttons, action buttons) become visible on `focus-visible`, not just hover
|
||||||
|
|
||||||
|
**Screen Readers and ARIA**
|
||||||
|
|
||||||
|
- **ARIA live regions** announce card/column creation, deletion, moves, filter changes, and drag-and-drop operations
|
||||||
|
- **Proper dialog semantics** - `role="dialog"`, `aria-modal`, `aria-labelledby` on all modals
|
||||||
|
- **Tab/tabpanel pattern** in settings with `role="tablist"`, `role="tab"`, `aria-selected`
|
||||||
|
- **Calendar grid** with `role="grid"`, `aria-selected` on date cells, labeled navigation
|
||||||
|
- **`aria-label`** on every icon-only button, color swatch, status indicator, and unlabeled input
|
||||||
|
- **`aria-pressed`** on all toggle buttons (theme, density, motion, label chips, priority)
|
||||||
|
- **Screen-reader-only labels** for search inputs, select dropdowns, and range sliders
|
||||||
|
|
||||||
|
**Toasts and Notifications**
|
||||||
|
|
||||||
|
- **8-second auto-dismiss** with pause-on-hover and pause-on-focus
|
||||||
|
- **Visible dismiss button** on every toast
|
||||||
|
- **`aria-live="polite"`** region so screen readers announce toast content without interrupting
|
||||||
|
|
||||||
|
**Motion**
|
||||||
|
|
||||||
|
- **`prefers-reduced-motion`** fully respected - both via CSS media query and an in-app toggle
|
||||||
|
- **No essential information** conveyed through animation alone
|
||||||
|
|
||||||
|
**Page Structure**
|
||||||
|
|
||||||
|
- **Dynamic page titles** - updates to reflect the current board name
|
||||||
|
- **Landmark regions** and semantic HTML throughout
|
||||||
|
- **Minimum touch targets** - 44px interactive area on small buttons via extended hit zones
|
||||||
|
|
||||||
|
### 🛡️ 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>
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
# OpenPylon — Local-First Kanban Board Design Document
|
|
||||||
|
|
||||||
**Date:** 2026-02-15
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
OpenPylon is a local-first Kanban board desktop app for personal projects and task management. No account required, no cloud sync — a fast, drag-and-drop board that saves to local JSON files. Replaces Trello ($5-10/mo), Asana ($11/mo), Monday.com ($9/mo).
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Runtime:** Tauri (Rust backend, system webview, ~5MB bundle)
|
|
||||||
- **Frontend:** React + TypeScript
|
|
||||||
- **State:** Zustand (monolithic store per board, debounced JSON persistence)
|
|
||||||
- **Styling:** Tailwind CSS + shadcn/ui
|
|
||||||
- **Drag & Drop:** dnd-kit
|
|
||||||
- **Undo/Redo:** zundo (Zustand temporal middleware)
|
|
||||||
|
|
||||||
## Architecture: Monolithic State Store
|
|
||||||
|
|
||||||
Single Zustand store per board, loaded entirely into memory from JSON on open. All mutations go through the store and auto-save back to disk with debounced writes (500ms). Board data is small (even 500 cards is ~1MB of JSON), so full in-memory loading is fine.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.openpylon/
|
|
||||||
├── settings.json # Global app settings
|
|
||||||
├── boards/
|
|
||||||
│ ├── board-<ulid>.json # One file per board
|
|
||||||
│ └── board-<ulid>.json
|
|
||||||
└── attachments/
|
|
||||||
└── board-<ulid>/ # Copied attachments (when setting enabled)
|
|
||||||
└── <ulid>-filename.png
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schema
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Board {
|
|
||||||
id: string; // ULID
|
|
||||||
title: string;
|
|
||||||
color: string; // Accent color stripe for board list
|
|
||||||
createdAt: string; // ISO 8601
|
|
||||||
updatedAt: string;
|
|
||||||
columns: Column[];
|
|
||||||
cards: Record<string, Card>; // Flat map, referenced by columns
|
|
||||||
labels: Label[]; // Board-level label definitions
|
|
||||||
settings: BoardSettings; // Per-board settings (attachment mode, etc.)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Column {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
cardIds: string[]; // Ordered references
|
|
||||||
width: "narrow" | "standard" | "wide"; // Collapsible widths
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Card {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string; // Markdown
|
|
||||||
labels: string[]; // Label IDs
|
|
||||||
checklist: ChecklistItem[];
|
|
||||||
dueDate: string | null;
|
|
||||||
attachments: Attachment[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Label {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChecklistItem {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
checked: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Attachment {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
path: string; // Absolute (link mode) or relative (copy mode)
|
|
||||||
mode: "link" | "copy";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key decisions:**
|
|
||||||
- ULIDs instead of UUIDs — sortable by creation time, no collisions
|
|
||||||
- Cards stored flat (`cards: Record<string, Card>`) with columns referencing via `cardIds[]` — drag-and-drop reordering is a simple array splice
|
|
||||||
- Labels defined at board level, referenced by ID on cards
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## State Management & Persistence
|
|
||||||
|
|
||||||
### Stores
|
|
||||||
|
|
||||||
- `useBoardStore` — active board's full state + all mutation actions
|
|
||||||
- `useAppStore` — global app state: theme, recent boards, settings, current view
|
|
||||||
|
|
||||||
### Persistence Flow
|
|
||||||
|
|
||||||
1. Board open: Tauri `fs.readTextFile()` → parse JSON → validate with Zod → hydrate Zustand store
|
|
||||||
2. On mutation: store subscribes to itself, debounces writes at 500ms
|
|
||||||
3. On board close / app quit: immediate flush via Tauri `window.onCloseRequested`
|
|
||||||
|
|
||||||
### Auto-Backup
|
|
||||||
|
|
||||||
On every successful save, rotate previous version to `board-<ulid>.backup.json` (one backup per board).
|
|
||||||
|
|
||||||
### Undo/Redo
|
|
||||||
|
|
||||||
zundo (Zustand temporal middleware) tracks state history. Ctrl+Z / Ctrl+Shift+Z. Capped at ~50 steps.
|
|
||||||
|
|
||||||
### Search
|
|
||||||
|
|
||||||
Global search reads all board JSON files from disk and searches card titles + descriptions. For personal Kanban (5-20 boards), this is instant. No index needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI Design
|
|
||||||
|
|
||||||
### Aesthetic Direction: Industrial Utility with Warmth
|
|
||||||
|
|
||||||
"Pylon" evokes infrastructure and strength. The app should feel like a well-made tool — a carpenter's organized workshop, not an IKEA showroom.
|
|
||||||
|
|
||||||
### Color Palette (OKLCH)
|
|
||||||
|
|
||||||
**Light mode:**
|
|
||||||
- Background: `oklch(97% 0.005 80)` — warm off-white
|
|
||||||
- Surface/cards: `oklch(99% 0.003 80)` — barely-there warmth
|
|
||||||
- Column background: `oklch(95% 0.008 80)` — subtle sand
|
|
||||||
- Primary accent: `oklch(55% 0.12 160)` — muted teal-green
|
|
||||||
- Text primary: `oklch(25% 0.015 50)` — warm near-black
|
|
||||||
- Text secondary: `oklch(55% 0.01 50)` — warm gray
|
|
||||||
- Danger/overdue: `oklch(55% 0.18 25)` — terracotta red
|
|
||||||
|
|
||||||
**Dark mode:**
|
|
||||||
- Background: `oklch(18% 0.01 50)` — warm dark
|
|
||||||
- Surface: `oklch(22% 0.01 50)`
|
|
||||||
- Cards: `oklch(25% 0.012 50)`
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
|
|
||||||
- **Headings:** Instrument Serif — heritage serif with personality
|
|
||||||
- **Body/cards:** Satoshi — clean geometric sans, readable at small sizes
|
|
||||||
- **Metadata (labels, dates, counts):** Geist Mono — reinforces "tool" identity
|
|
||||||
|
|
||||||
**Scale:**
|
|
||||||
- Board title: `clamp(1.25rem, 2vw, 1.5rem)`, bold
|
|
||||||
- Column headers: `0.8rem` uppercase, `letter-spacing: 0.08em`, weight 600
|
|
||||||
- Card titles: `0.875rem`, weight 500
|
|
||||||
- Card metadata: `0.75rem` monospace
|
|
||||||
|
|
||||||
### App Shell Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────┐
|
|
||||||
│ ← Boards Sprint Planning ⌘K ⚙ │
|
|
||||||
├──────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ TO DO IN PROGRESS DONE │
|
|
||||||
│ ───── 4 ─────────── 2 ──── 3 │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
|
||||||
│ │ Card title │ │ Card title │ │ Card title │ │
|
|
||||||
│ │ 🟢🔵 Feb28 ▮▮▯│ │ 🟢 ▮▮▮▮│ │ ▮▮▯ │ │
|
|
||||||
│ └────────────┘ └────────────┘ └────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ + Add card + Add card + Add card │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key layout decisions:**
|
|
||||||
- No vertical column dividers — whitespace gaps (24-32px) instead
|
|
||||||
- Column headers: uppercase, tracked-out, small — like section dividers
|
|
||||||
- Card count as quiet number beside underline, not a badge
|
|
||||||
- Command palette (`Ctrl+K`) replaces search icon
|
|
||||||
- Theme toggle lives in settings, not top bar
|
|
||||||
- Board title is click-to-edit inline, no `[edit]` button
|
|
||||||
|
|
||||||
### Card Design
|
|
||||||
|
|
||||||
- **Label dots:** 8px colored circles in a row, hover for tooltip with name
|
|
||||||
- **Due date:** Monospace, right-aligned, no icon. Overdue turns terracotta with subtle tint.
|
|
||||||
- **Checklist:** Tiny progress bar (filled/unfilled blocks), not "3/4" text
|
|
||||||
- **No card borders.** Subtle shadow (`0 1px 3px oklch(0% 0 0 / 0.06)`) for separation.
|
|
||||||
- **Hover:** `translateY(-1px)` lift with faint shadow deepening, spring physics, 150ms
|
|
||||||
- **Drag ghost:** 5-degree rotation, `scale(1.03)`, `opacity(0.9)`, elevated shadow
|
|
||||||
|
|
||||||
### Column Widths
|
|
||||||
|
|
||||||
Columns support three widths: narrow (titles only), standard, wide (active focus). Double-click header to cycle. Adds spatial meaning.
|
|
||||||
|
|
||||||
### Card Detail Modal (Two-Panel)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────┐
|
|
||||||
│ Fix auth token refresh ✕ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────┐ ┌────────────────────┐ │
|
|
||||||
│ │ │ │ LABELS │ │
|
|
||||||
│ │ Markdown description │ │ 🟢 Bug 🔵 Backend │ │
|
|
||||||
│ │ with live preview │ │ │ │
|
|
||||||
│ │ │ │ DUE DATE │ │
|
|
||||||
│ │ │ │ Feb 28, 2026 │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ │ │ CHECKLIST 3/4 │ │
|
|
||||||
│ │ │ │ ✓ Research APIs │ │
|
|
||||||
│ │ │ │ ✓ Write tests │ │
|
|
||||||
│ │ │ │ ✓ Implement │ │
|
|
||||||
│ │ │ │ ○ Code review │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ │ │ ATTACHMENTS │ │
|
|
||||||
│ │ │ │ spec.pdf │ │
|
|
||||||
│ └─────────────────────────┘ └────────────────────┘ │
|
|
||||||
└──────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- Left panel (60%): Title (inline edit) + markdown description (edit/preview toggle)
|
|
||||||
- Right sidebar (40%): Labels, due date, checklist, attachments. Each collapsible.
|
|
||||||
- No save button — auto-persist with subtle "Saved" indicator
|
|
||||||
- **Card-to-modal morph animation** via Framer Motion `layoutId` — modal grows from card position
|
|
||||||
|
|
||||||
### Command Palette (`Ctrl+K`)
|
|
||||||
|
|
||||||
Using shadcn's `cmdk` component:
|
|
||||||
- Search all cards across all boards by title/description
|
|
||||||
- Switch between boards
|
|
||||||
- Create new cards/boards
|
|
||||||
- Toggle theme
|
|
||||||
- Open settings
|
|
||||||
- Navigate to specific column
|
|
||||||
- Filter current board by label/date
|
|
||||||
|
|
||||||
### Board List (Home Screen)
|
|
||||||
|
|
||||||
Grid of board cards with:
|
|
||||||
- Color accent stripe at top (user-chosen per board)
|
|
||||||
- Title, card count, column count
|
|
||||||
- Relative time ("2 min ago", "Yesterday")
|
|
||||||
- Right-click context menu: Duplicate, Export, Delete, Change color
|
|
||||||
- Empty state: "Create your first board" + single button
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
|
||||||
|
|
||||||
### Global
|
|
||||||
|
|
||||||
| Action | Shortcut |
|
|
||||||
|---|---|
|
|
||||||
| Command palette | `Ctrl+K` |
|
|
||||||
| New card in focused column | `N` |
|
|
||||||
| New board | `Ctrl+N` |
|
|
||||||
| Undo | `Ctrl+Z` |
|
|
||||||
| Redo | `Ctrl+Shift+Z` |
|
|
||||||
| Settings | `Ctrl+,` |
|
|
||||||
| Close modal / cancel | `Escape` |
|
|
||||||
| Save & close card detail | `Ctrl+Enter` |
|
|
||||||
|
|
||||||
### Board Navigation
|
|
||||||
|
|
||||||
- `Arrow Left/Right` — focus prev/next column
|
|
||||||
- `Arrow Up/Down` — focus prev/next card in column
|
|
||||||
- `Enter` — open focused card detail
|
|
||||||
- `Space` — quick-toggle first unchecked checklist item
|
|
||||||
- `D` — set/edit due date on focused card
|
|
||||||
- `L` — open label picker on focused card
|
|
||||||
|
|
||||||
### Drag-and-Drop Keyboard
|
|
||||||
|
|
||||||
dnd-kit keyboard sensor: `Space` to pick up, arrows to move, `Space` to drop, `Escape` to cancel. Movements announced via `aria-live` region.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
- All interactive elements reachable via Tab
|
|
||||||
- Focus indicators: `2px solid` accent color, `2px offset`, visible in both themes
|
|
||||||
- Modal focus trapping
|
|
||||||
- Column/card counts via `aria-label`
|
|
||||||
- `prefers-reduced-motion`: all animations collapse to instant
|
|
||||||
- `prefers-contrast`: increased shadow intensity, subtle borders restored
|
|
||||||
- Minimum touch target: 44x44px on all buttons
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Import/Export
|
|
||||||
|
|
||||||
### Export
|
|
||||||
|
|
||||||
- **JSON:** The board file itself is the export. Save As dialog.
|
|
||||||
- **CSV:** Flattened — one row per card with all fields.
|
|
||||||
- **ZIP:** For boards with copy-mode attachments — board JSON + attachments folder.
|
|
||||||
|
|
||||||
### Import
|
|
||||||
|
|
||||||
- **OpenPylon JSON:** Drop file onto board list or use File > Import. Schema validation + preview before importing.
|
|
||||||
- **CSV:** Import wizard — map columns, preview rows, choose target board.
|
|
||||||
- **Trello JSON:** Dedicated adapter mapping Trello schema to OpenPylon.
|
|
||||||
- **Drag-and-drop import:** Dropping `.json` or `.csv` anywhere triggers import flow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- **Corrupted board file:** Recovery dialog — inspect in file explorer or restore from `.backup.json`
|
|
||||||
- **Data directory inaccessible:** Dialog to choose new directory on startup
|
|
||||||
- **Disk full:** Inline toast, changes preserved in memory, retry every 30s
|
|
||||||
- **File locked:** Warning dialog
|
|
||||||
- **Schema migration:** On load, validate with Zod, add missing fields with defaults, preserve unknown fields
|
|
||||||
- **Drag edge cases:** Empty column droppable, drop outside cancels with spring return
|
|
||||||
|
|
||||||
## Micro-Interactions
|
|
||||||
|
|
||||||
| Interaction | Animation | Duration |
|
|
||||||
|---|---|---|
|
|
||||||
| Card appears (new) | Fade in + slide down | 200ms, spring |
|
|
||||||
| Card drag start | Lift + rotate + shadow | 150ms |
|
|
||||||
| Card drop | Settle with slight bounce | 250ms, spring |
|
|
||||||
| Column add | Slide in from right | 300ms |
|
|
||||||
| Card detail open | Morph from card position | 250ms |
|
|
||||||
| Card detail close | Reverse morph to card | 200ms |
|
|
||||||
| Checklist check | Strikethrough sweep + fill | 200ms |
|
|
||||||
| Board switch | Crossfade | 300ms |
|
|
||||||
|
|
||||||
All animations respect `prefers-reduced-motion`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Empty States
|
|
||||||
|
|
||||||
- **No boards:** "Create your first board" + button + minimal illustration
|
|
||||||
- **Empty column:** Dashed border area + "Drag cards here or click + to add"
|
|
||||||
- **No search results:** "No matches" + suggestion to broaden
|
|
||||||
- **No labels:** "Create your first label" + color swatches
|
|
||||||
@@ -2,12 +2,11 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<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>
|
||||||
|
|||||||
1339
package-lock.json
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "openpylon",
|
"name": "openpylon",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.1.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",
|
||||||
@@ -41,7 +45,10 @@
|
|||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"png-to-ico": "^3.0.1",
|
||||||
|
"puppeteer-core": "^24.37.3",
|
||||||
"shadcn": "^3.8.4",
|
"shadcn": "^3.8.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
|
||||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
5543
src-tauri/Cargo.lock
generated
Normal file
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "openpylon"
|
name = "openpylon"
|
||||||
version = "0.1.0"
|
version = "1.1.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/**" }]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 903 B |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 40 KiB |
@@ -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.1.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": {
|
||||||
|
|||||||
128
src/App.tsx
@@ -1,23 +1,98 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { getCurrentWindow, LogicalSize, LogicalPosition } from "@tauri-apps/api/window";
|
||||||
|
import { AnimatePresence, motion, MotionConfig } 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";
|
||||||
|
import { useAnnounceStore } from "@/hooks/useAnnounce";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const initialized = useAppStore((s) => s.initialized);
|
const initialized = useAppStore((s) => s.initialized);
|
||||||
const init = useAppStore((s) => s.init);
|
const init = useAppStore((s) => s.init);
|
||||||
const view = useAppStore((s) => s.view);
|
const view = useAppStore((s) => s.view);
|
||||||
|
const reduceMotion = useAppStore((s) => s.settings.reduceMotion);
|
||||||
|
const boardTitle = useBoardStore((s) => s.board?.title);
|
||||||
|
const announcement = useAnnounceStore((s) => s.message);
|
||||||
|
|
||||||
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 +104,20 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOpenShortcutHelp() {
|
||||||
|
setShortcutHelpOpen(true);
|
||||||
|
}
|
||||||
|
document.addEventListener("open-shortcut-help", handleOpenShortcutHelp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("open-shortcut-help", handleOpenShortcutHelp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = boardTitle ? `${boardTitle} - OpenPylon` : "OpenPylon";
|
||||||
|
}, [boardTitle, view]);
|
||||||
|
|
||||||
const handleOpenSettings = useCallback(() => {
|
const handleOpenSettings = useCallback(() => {
|
||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -46,12 +135,43 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<MotionConfig reducedMotion={reduceMotion ? "always" : "user"}>
|
||||||
|
<div aria-live="assertive" aria-atomic="true" className="sr-only">
|
||||||
|
{announcement}
|
||||||
|
</div>
|
||||||
<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} />
|
||||||
|
</MotionConfig>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -44,6 +44,7 @@ export function AddCardInput({ columnId, onClose }: AddCardInputProps) {
|
|||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (!value.trim()) onClose();
|
if (!value.trim()) onClose();
|
||||||
}}
|
}}
|
||||||
|
aria-label="Card title"
|
||||||
placeholder="Card title..."
|
placeholder="Card title..."
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full resize-none rounded-lg bg-pylon-surface p-3 text-sm text-pylon-text shadow-sm outline-none placeholder:text-pylon-text-secondary focus:ring-1 focus:ring-pylon-accent"
|
className="w-full resize-none rounded-lg bg-pylon-surface p-3 text-sm text-pylon-text shadow-sm outline-none placeholder:text-pylon-text-secondary focus:ring-1 focus:ring-pylon-accent"
|
||||||
|
|||||||
@@ -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,141 +199,174 @@ 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) => {
|
||||||
const { active, over } = event;
|
try {
|
||||||
|
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) {
|
||||||
moveColumn(fromIndex, toIndex);
|
moveColumn(fromIndex, toIndex);
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type === "card") {
|
|
||||||
// Card reordering within same column (cross-column already handled in onDragOver)
|
|
||||||
const activeCardId = active.id as string;
|
|
||||||
const overId = over.id as string;
|
|
||||||
|
|
||||||
const activeColumn = findColumnByCardId(board, activeCardId);
|
|
||||||
if (!activeColumn) {
|
|
||||||
setActiveId(null);
|
|
||||||
setActiveType(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overType = over.data.current?.type;
|
|
||||||
|
|
||||||
if (overType === "card") {
|
|
||||||
const overColumn = findColumnByCardId(board, overId);
|
|
||||||
if (!overColumn) {
|
|
||||||
setActiveId(null);
|
|
||||||
setActiveType(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeColumn.id === overColumn.id) {
|
|
||||||
// Within same column, reorder
|
|
||||||
const oldIndex = activeColumn.cardIds.indexOf(activeCardId);
|
|
||||||
const newIndex = activeColumn.cardIds.indexOf(overId);
|
|
||||||
if (oldIndex !== newIndex) {
|
|
||||||
moveCard(activeCardId, activeColumn.id, activeColumn.id, newIndex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (overType === "column") {
|
} else if (type === "card") {
|
||||||
// Dropped on an empty column droppable
|
const activeCardId = active.id as string;
|
||||||
const columnId = over.data.current?.columnId as string | undefined;
|
const overId = over.id as string;
|
||||||
const targetColumnId = columnId ?? (over.id as string);
|
|
||||||
const targetColumn = board.columns.find(
|
|
||||||
(c) => c.id === targetColumnId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetColumn && activeColumn.id !== targetColumn.id) {
|
const activeColumn = findColumnByCardId(currentBoard, activeCardId);
|
||||||
moveCard(
|
if (!activeColumn) return;
|
||||||
activeCardId,
|
|
||||||
activeColumn.id,
|
const overType = over.data.current?.type;
|
||||||
targetColumn.id,
|
|
||||||
targetColumn.cardIds.length
|
if (overType === "card") {
|
||||||
|
const overColumn = findColumnByCardId(currentBoard, overId);
|
||||||
|
if (!overColumn) return;
|
||||||
|
|
||||||
|
if (activeColumn.id === overColumn.id) {
|
||||||
|
const oldIndex = activeColumn.cardIds.indexOf(activeCardId);
|
||||||
|
const newIndex = activeColumn.cardIds.indexOf(overId);
|
||||||
|
if (oldIndex !== newIndex) {
|
||||||
|
moveCard(activeCardId, activeColumn.id, activeColumn.id, newIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (overType === "column") {
|
||||||
|
const columnId = over.data.current?.columnId as string | undefined;
|
||||||
|
const targetColumnId = columnId ?? (over.id as string);
|
||||||
|
const targetColumn = currentBoard.columns.find(
|
||||||
|
(c) => c.id === targetColumnId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (targetColumn && activeColumn.id !== targetColumn.id) {
|
||||||
|
moveCard(
|
||||||
|
activeCardId,
|
||||||
|
activeColumn.id,
|
||||||
|
targetColumn.id,
|
||||||
|
targetColumn.cardIds.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearDragState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveId(null);
|
|
||||||
setActiveType(null);
|
|
||||||
},
|
},
|
||||||
[board, moveCard, moveColumn]
|
[handleDragEnd]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!board) {
|
if (!board) {
|
||||||
@@ -249,26 +388,62 @@ export function BoardView() {
|
|||||||
const columnIds = board.columns.map((c) => c.id);
|
const columnIds = board.columns.map((c) => c.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 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
|
||||||
{board.columns.map((column) => (
|
ref={osRef}
|
||||||
<KanbanColumn
|
className="min-h-0 flex-1"
|
||||||
key={column.id}
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { y: "hidden" } }}
|
||||||
column={column}
|
defer
|
||||||
onCardClick={setSelectedCardId}
|
>
|
||||||
/>
|
<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) => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={column.id}
|
||||||
|
column={column}
|
||||||
|
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,23 +491,26 @@ 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}>
|
||||||
{activeCard ? (
|
<AnimatePresence>
|
||||||
<CardOverlay card={activeCard} boardLabels={board.labels} />
|
{activeCard ? (
|
||||||
) : activeColumn ? (
|
<CardOverlay key="card-overlay" card={activeCard} boardLabels={board.labels} />
|
||||||
<ColumnOverlay column={activeColumn} />
|
) : activeColumn ? (
|
||||||
) : null}
|
<ColumnOverlay key="column-overlay" column={activeColumn} />
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
<CardDetailModal
|
<CardDetailModal
|
||||||
cardId={selectedCardId}
|
cardId={selectedCardId}
|
||||||
onClose={() => setSelectedCardId(null)}
|
onClose={() => { setSelectedCardId(null); }}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.9;
|
||||||
|
if (days <= 30) return 0.8;
|
||||||
|
return 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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,61 +83,310 @@ 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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
|
className="py-1"
|
||||||
|
{...attributes}
|
||||||
|
{...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 (
|
return (
|
||||||
<motion.button
|
<ContextMenu>
|
||||||
ref={setNodeRef}
|
<ContextMenuTrigger asChild>
|
||||||
style={style}
|
<motion.button
|
||||||
onClick={handleClick}
|
ref={(node) => {
|
||||||
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"
|
setNodeRef(node);
|
||||||
initial={prefersReducedMotion ? false : { opacity: 0, y: -8 }}
|
(cardRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
|
||||||
animate={{ opacity: 1, y: 0 }}
|
}}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
style={{
|
||||||
{...attributes}
|
transform: CSS.Transform.toString(transform),
|
||||||
{...listeners}
|
transition,
|
||||||
>
|
padding: `calc(0.75rem * var(--density-factor))`,
|
||||||
{/* Label dots */}
|
opacity: getAgingOpacity(card.updatedAt),
|
||||||
{card.labels.length > 0 && (
|
}}
|
||||||
<div className="mb-2">
|
onClick={handleClick}
|
||||||
<LabelDots labelIds={card.labels} boardLabels={boardLabels} />
|
className={`w-full rounded-lg bg-pylon-surface shadow-sm text-left ${
|
||||||
</div>
|
isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : ""
|
||||||
)}
|
}`}
|
||||||
|
layoutId={prefersReducedMotion ? undefined : `card-${card.id}`}
|
||||||
{/* Card title */}
|
variants={fadeSlideUp}
|
||||||
<p className="text-sm font-medium text-pylon-text">{card.title}</p>
|
initial={prefersReducedMotion ? false : "hidden"}
|
||||||
|
animate="visible"
|
||||||
{/* Footer row: due date + checklist */}
|
whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }}
|
||||||
{(hasDueDate || card.checklist.length > 0) && (
|
whileTap={{ scale: 0.98 }}
|
||||||
<div className="mt-2 flex items-center gap-3">
|
transition={springs.bouncy}
|
||||||
{dueDate && (
|
layout={!prefersReducedMotion}
|
||||||
<span
|
{...attributes}
|
||||||
className={`font-mono text-xs ${
|
{...listeners}
|
||||||
overdue
|
role="article"
|
||||||
? "rounded bg-pylon-danger/10 px-1 py-0.5 text-pylon-danger"
|
aria-label={card.title}
|
||||||
: "text-pylon-text-secondary"
|
>
|
||||||
}`}
|
{/* Cover color bar */}
|
||||||
>
|
{card.coverColor && (
|
||||||
{format(dueDate, "MMM d")}
|
<div
|
||||||
</span>
|
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`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{card.checklist.length > 0 && (
|
|
||||||
<ChecklistBar checklist={card.checklist} />
|
{/* Label dots */}
|
||||||
|
{card.labels.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<LabelDots labelIds={card.labels} boardLabels={boardLabels} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
{/* Card title */}
|
||||||
</motion.button>
|
<p className="text-sm font-medium text-pylon-text">{card.title}</p>
|
||||||
|
|
||||||
|
{/* Footer row: priority + due date + checklist + icons */}
|
||||||
|
{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
{card.priority !== "none" && (
|
||||||
|
<span
|
||||||
|
className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
|
||||||
|
style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
|
||||||
|
aria-label={`Priority: ${card.priority}`}
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dueDateStatus && card.dueDate && (
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs rounded px-1 py-0.5 ${dueDateStatus.color} ${dueDateStatus.bgColor}`}
|
||||||
|
aria-label={`Due: ${format(new Date(card.dueDate), "MMM d")} - ${dueDateStatus.label}`}
|
||||||
|
>
|
||||||
|
{format(new Date(card.dueDate), "MMM d")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.checklist.length > 0 && (
|
||||||
|
<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" aria-label={`${card.attachments.length} attachment${card.attachments.length !== 1 ? "s" : ""}`} role="img">
|
||||||
|
<Paperclip className="size-3" />
|
||||||
|
<span className="font-mono text-xs">{card.attachments.length}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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}
|
||||||
|
aria-label="Has description"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function ChecklistBar({ checklist }: ChecklistBarProps) {
|
|||||||
if (checklist.length === 0) return null;
|
if (checklist.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-px">
|
<div className="flex items-center gap-px" aria-label={`Checklist: ${checklist.filter(i => i.checked).length} of ${checklist.length} complete`} role="img">
|
||||||
{checklist.map((item) => (
|
{checklist.map((item) => (
|
||||||
<span
|
<span
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -68,6 +88,7 @@ export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
|
|||||||
onChange={(e) => setEditValue(e.target.value)}
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
onBlur={commitRename}
|
onBlur={commitRename}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label="Column title"
|
||||||
className="h-5 w-full bg-transparent font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary outline-none"
|
className="h-5 w-full bg-transparent font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary outline-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -81,8 +102,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>
|
||||||
|
|
||||||
@@ -91,7 +118,8 @@ export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="shrink-0 text-pylon-text-secondary opacity-0 transition-opacity group-hover/column:opacity-100 hover:text-pylon-text"
|
className="shrink-0 text-pylon-text-secondary opacity-0 transition-opacity group-hover/column:opacity-100 focus-visible:opacity-100 hover:text-pylon-text"
|
||||||
|
aria-label="Column options"
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-3.5" />
|
<MoreHorizontal className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -105,27 +133,60 @@ 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}
|
||||||
|
aria-label={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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/components/board/FilterBar.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
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..."
|
||||||
|
aria-label="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)}
|
||||||
|
aria-label={`Filter by label: ${label.name}`}
|
||||||
|
aria-pressed={filters.labels.includes(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"] })}
|
||||||
|
aria-label="Filter by due date"
|
||||||
|
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"] })}
|
||||||
|
aria-label="Filter by 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} aria-label="Close filter bar" 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 (
|
||||||
<CardThumbnail
|
<li key={cardId}>
|
||||||
key={cardId}
|
<CardThumbnail
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export function LabelDots({ labelIds, boardLabels }: LabelDotsProps) {
|
|||||||
<span
|
<span
|
||||||
className="inline-block size-2 shrink-0 rounded-full"
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: label.color }}
|
style={{ backgroundColor: label.color }}
|
||||||
|
aria-label={label.name}
|
||||||
|
role="img"
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{label.name}</TooltipContent>
|
<TooltipContent>{label.name}</TooltipContent>
|
||||||
|
|||||||
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,18 +86,104 @@ 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>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
|
aria-label={`Open board: ${board.title} - ${board.cardCount} cards, ${board.columnCount} columns, updated ${relativeTime}`}
|
||||||
className="group flex w-full flex-col rounded-lg bg-pylon-surface shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md text-left"
|
className="group flex w-full flex-col rounded-lg bg-pylon-surface shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md text-left"
|
||||||
>
|
>
|
||||||
{/* Color accent stripe */}
|
{/* Color accent stripe */}
|
||||||
@@ -111,6 +217,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
@@ -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}
|
||||||
</p>
|
initial="hidden"
|
||||||
<div className="flex items-center gap-2">
|
animate="visible"
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
transition={springs.gentle}
|
||||||
<Plus className="size-4" />
|
>
|
||||||
New Board
|
<div className="text-center">
|
||||||
</Button>
|
<h2 className="font-heading text-2xl text-pylon-text">
|
||||||
<ImportExportButtons />
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="lg" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Create Board
|
||||||
|
</Button>
|
||||||
|
<ImportButton />
|
||||||
|
</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}
|
||||||
</div>
|
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>
|
||||||
|
</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,45 @@ 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); }}
|
||||||
|
aria-pressed={template === t && !selectedUserTemplate}
|
||||||
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); }}
|
||||||
|
aria-pressed={selectedUserTemplate?.id === ut.id}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
aria-label="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 (
|
||||||
@@ -32,6 +52,7 @@ export function AttachmentSection({
|
|||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||||
|
aria-label="Add attachment"
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -49,9 +70,16 @@ 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 focus-visible: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 focus-visible:opacity-100"
|
||||||
aria-label="Remove attachment"
|
aria-label="Remove attachment"
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
|
|||||||
286
src/components/card-detail/CalendarPopover.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
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"
|
||||||
|
aria-label="Previous month"
|
||||||
|
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")}
|
||||||
|
aria-label="Select month"
|
||||||
|
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")}
|
||||||
|
aria-label="Select year"
|
||||||
|
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"
|
||||||
|
aria-label="Next month"
|
||||||
|
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" role="grid" aria-label="Calendar days">
|
||||||
|
{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)}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-label={format(day, "EEEE, MMMM d, yyyy")}
|
||||||
|
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, useReducedMotion } 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,222 @@ 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;
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
|
const instant = { duration: 0 };
|
||||||
|
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<Element | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
triggerRef.current = document.activeElement;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
modalRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else if (triggerRef.current instanceof HTMLElement) {
|
||||||
|
triggerRef.current.focus();
|
||||||
|
triggerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
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 && (
|
<>
|
||||||
<>
|
{/* Backdrop */}
|
||||||
{/* Hidden accessible description */}
|
<motion.div
|
||||||
<DialogDescription className="sr-only">
|
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
||||||
Card detail editor
|
initial={{ opacity: 0 }}
|
||||||
</DialogDescription>
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={prefersReducedMotion ? instant : { 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}
|
||||||
<InlineTitle
|
>
|
||||||
|
<motion.div
|
||||||
|
ref={modalRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="card-detail-title"
|
||||||
|
layoutId={prefersReducedMotion ? undefined : `card-${cardId}`}
|
||||||
|
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
|
||||||
|
initial={prefersReducedMotion ? { opacity: 0 } : undefined}
|
||||||
|
animate={prefersReducedMotion ? { opacity: 1 } : undefined}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : undefined}
|
||||||
|
transition={prefersReducedMotion ? instant : 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 */}
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
className="max-h-[calc(85vh-4rem)]"
|
||||||
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
|
||||||
|
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
|
||||||
cardId={cardId}
|
cardId={cardId}
|
||||||
title={card.title}
|
cardLabelIds={card.labels}
|
||||||
updateCard={updateCard}
|
boardLabels={boardLabels}
|
||||||
/>
|
/>
|
||||||
</DialogHeader>
|
</motion.div>
|
||||||
|
|
||||||
<MarkdownEditor cardId={cardId} value={card.description} />
|
<motion.div
|
||||||
</div>
|
className="rounded-lg bg-pylon-column/50 p-4"
|
||||||
|
variants={fadeSlideUp}
|
||||||
|
transition={springs.bouncy}
|
||||||
|
>
|
||||||
|
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Vertical separator */}
|
{/* Row 2: Checklist + Description */}
|
||||||
<Separator orientation="vertical" className="hidden sm:block" />
|
<motion.div
|
||||||
|
className="rounded-lg bg-pylon-column/50 p-4"
|
||||||
|
variants={fadeSlideUp}
|
||||||
|
transition={springs.bouncy}
|
||||||
|
>
|
||||||
|
<ChecklistSection
|
||||||
|
cardId={cardId}
|
||||||
|
checklist={card.checklist}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Right sidebar (40%) */}
|
<motion.div
|
||||||
<div className="flex flex-col gap-5 overflow-y-auto border-t border-border p-5 sm:w-[40%] sm:border-t-0">
|
className="rounded-lg bg-pylon-column/50 p-4"
|
||||||
<LabelPicker
|
variants={fadeSlideUp}
|
||||||
cardId={cardId}
|
transition={springs.bouncy}
|
||||||
cardLabelIds={card.labels}
|
>
|
||||||
boardLabels={boardLabels}
|
<MarkdownEditor cardId={cardId} value={card.description} />
|
||||||
/>
|
</motion.div>
|
||||||
|
|
||||||
<Separator />
|
{/* 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>
|
||||||
|
|
||||||
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
|
<motion.div
|
||||||
|
className="rounded-lg bg-pylon-column/50 p-4"
|
||||||
|
variants={fadeSlideUp}
|
||||||
|
transition={springs.bouncy}
|
||||||
|
>
|
||||||
|
<CoverColorPicker
|
||||||
|
cardId={cardId}
|
||||||
|
coverColor={card.coverColor}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<Separator />
|
{/* 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
|
||||||
|
cardId={cardId}
|
||||||
|
attachments={card.attachments}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<ChecklistSection
|
{/* Row 5: Comments (full width) */}
|
||||||
cardId={cardId}
|
<motion.div
|
||||||
checklist={card.checklist}
|
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
|
||||||
/>
|
variants={fadeSlideUp}
|
||||||
|
transition={springs.bouncy}
|
||||||
<Separator />
|
>
|
||||||
|
<CommentsSection cardId={cardId} comments={card.comments} />
|
||||||
<AttachmentSection
|
</motion.div>
|
||||||
cardId={cardId}
|
</motion.div>
|
||||||
attachments={card.attachments}
|
</OverlayScrollbarsComponent>
|
||||||
attachmentMode={attachmentMode}
|
</motion.div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
</>
|
</AnimatePresence>
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</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 +269,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 +280,78 @@ 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"
|
aria-label="Card title"
|
||||||
|
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
|
||||||
|
id="card-detail-title"
|
||||||
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"
|
||||||
|
aria-label="No cover color"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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}
|
||||||
|
aria-label={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,31 +67,51 @@ 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 items-center justify-between">
|
<div className="flex flex-col gap-1.5">
|
||||||
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
<div className="flex items-center justify-between">
|
||||||
Checklist
|
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||||||
</h4>
|
Checklist
|
||||||
|
</h4>
|
||||||
|
{checklist.length > 0 && (
|
||||||
|
<span className="font-mono text-xs text-pylon-text-secondary">
|
||||||
|
{checked}/{checklist.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{checklist.length > 0 && (
|
{checklist.length > 0 && (
|
||||||
<span className="font-mono text-xs text-pylon-text-secondary">
|
<div className="h-1 w-full overflow-hidden rounded-full bg-pylon-column">
|
||||||
{checked}/{checklist.length}
|
<div
|
||||||
</span>
|
className="h-full rounded-full bg-pylon-accent transition-all duration-300"
|
||||||
|
style={{ width: `${(checked / checklist.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items */}
|
||||||
<div className="flex flex-col gap-1">
|
<OverlayScrollbarsComponent
|
||||||
{checklist.map((item) => (
|
className="max-h-[160px]"
|
||||||
<ChecklistRow
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
|
||||||
key={item.id}
|
defer
|
||||||
cardId={cardId}
|
>
|
||||||
item={item}
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
onToggle={() => toggleChecklistItem(cardId, item.id)}
|
<SortableContext items={checklist.map((item) => item.id)} strategy={verticalListSortingStrategy}>
|
||||||
onUpdate={(text) => updateChecklistItem(cardId, item.id, text)}
|
<div className="flex flex-col gap-1">
|
||||||
onDelete={() => deleteChecklistItem(cardId, item.id)}
|
{checklist.map((item) => (
|
||||||
/>
|
<ChecklistRow
|
||||||
))}
|
key={item.id}
|
||||||
</div>
|
cardId={cardId}
|
||||||
|
item={item}
|
||||||
|
onToggle={() => toggleChecklistItem(cardId, item.id)}
|
||||||
|
onUpdate={(text) => updateChecklistItem(cardId, item.id, text)}
|
||||||
|
onDelete={() => deleteChecklistItem(cardId, item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
|
||||||
{/* Add item */}
|
{/* Add item */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -71,6 +121,7 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
|
|||||||
onChange={(e) => setNewItemText(e.target.value)}
|
onChange={(e) => setNewItemText(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Add item..."
|
placeholder="Add item..."
|
||||||
|
aria-label="New checklist item"
|
||||||
className="h-7 flex-1 rounded-md bg-pylon-column px-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
|
className="h-7 flex-1 rounded-md bg-pylon-column px-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,6 +141,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,11 +166,28 @@ 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 focus-visible:opacity-100"
|
||||||
|
aria-label="Reorder item"
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-3" />
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onChange={onToggle}
|
onChange={onToggle}
|
||||||
|
aria-label={`Mark "${item.text}" as ${item.checked ? "incomplete" : "complete"}`}
|
||||||
className="size-3.5 shrink-0 cursor-pointer accent-pylon-accent"
|
className="size-3.5 shrink-0 cursor-pointer accent-pylon-accent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -146,7 +218,7 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
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/item: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/item:opacity-100 focus-visible:opacity-100"
|
||||||
aria-label="Delete item"
|
aria-label="Delete item"
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
|
|||||||
98
src/components/card-detail/CommentsSection.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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}
|
||||||
|
aria-label="Add a comment"
|
||||||
|
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 focus-visible: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,70 +14,67 @@ 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 */}
|
||||||
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
<div className="flex items-center justify-between">
|
||||||
Due Date
|
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||||||
</h4>
|
Due Date
|
||||||
|
</h4>
|
||||||
{/* Current date display */}
|
|
||||||
{dateObj && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Date input + clear */}
|
|
||||||
<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 && (
|
{dueDate && (
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className="text-pylon-text-secondary hover:text-pylon-danger"
|
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"
|
||||||
>
|
>
|
||||||
Clear
|
<X className="size-3.5" />
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</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,
|
||||||
@@ -69,6 +70,7 @@ export function LabelPicker({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||||
|
aria-label="Manage labels"
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -81,29 +83,37 @@ 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
|
||||||
{boardLabels.map((label) => {
|
className="max-h-40"
|
||||||
const isSelected = cardLabelIds.includes(label.id);
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
|
||||||
return (
|
defer
|
||||||
<button
|
>
|
||||||
key={label.id}
|
<div className="flex flex-col gap-1">
|
||||||
onClick={() => toggleCardLabel(cardId, label.id)}
|
{boardLabels.map((label) => {
|
||||||
className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-pylon-column"
|
const isSelected = cardLabelIds.includes(label.id);
|
||||||
>
|
return (
|
||||||
<span
|
<button
|
||||||
className="size-3 shrink-0 rounded-full"
|
key={label.id}
|
||||||
style={{ backgroundColor: label.color }}
|
onClick={() => toggleCardLabel(cardId, label.id)}
|
||||||
/>
|
className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-pylon-column"
|
||||||
<span className="flex-1 truncate text-pylon-text">
|
aria-pressed={isSelected}
|
||||||
{label.name}
|
aria-label={`${isSelected ? "Remove" : "Add"} label: ${label.name}`}
|
||||||
</span>
|
>
|
||||||
{isSelected && (
|
<span
|
||||||
<Check className="size-3.5 shrink-0 text-pylon-accent" />
|
className="size-3 shrink-0 rounded-full"
|
||||||
)}
|
style={{ backgroundColor: label.color }}
|
||||||
</button>
|
/>
|
||||||
);
|
<span className="flex-1 truncate text-pylon-text">
|
||||||
})}
|
{label.name}
|
||||||
</div>
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="size-3.5 shrink-0 text-pylon-accent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create new label */}
|
{/* Create new label */}
|
||||||
@@ -115,6 +125,7 @@ export function LabelPicker({
|
|||||||
onChange={(e) => setNewLabelName(e.target.value)}
|
onChange={(e) => setNewLabelName(e.target.value)}
|
||||||
onKeyDown={handleCreateKeyDown}
|
onKeyDown={handleCreateKeyDown}
|
||||||
placeholder="Label name..."
|
placeholder="Label name..."
|
||||||
|
aria-label="New label name"
|
||||||
className="h-7 rounded-md bg-pylon-column px-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
|
className="h-7 rounded-md bg-pylon-column px-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -123,6 +134,7 @@ export function LabelPicker({
|
|||||||
key={color}
|
key={color}
|
||||||
onClick={() => setNewLabelColor(color)}
|
onClick={() => setNewLabelColor(color)}
|
||||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||||
|
aria-label={`Color ${color}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -64,6 +76,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
|||||||
variant={mode === "edit" ? "secondary" : "ghost"}
|
variant={mode === "edit" ? "secondary" : "ghost"}
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => setMode("edit")}
|
onClick={() => setMode("edit")}
|
||||||
|
aria-pressed={mode === "edit"}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@@ -71,6 +84,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant={mode === "preview" ? "secondary" : "ghost"}
|
variant={mode === "preview" ? "secondary" : "ghost"}
|
||||||
size="xs"
|
size="xs"
|
||||||
|
aria-pressed={mode === "preview"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Save before switching to preview
|
// Save before switching to preview
|
||||||
if (mode === "edit") {
|
if (mode === "edit") {
|
||||||
@@ -90,17 +104,26 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
|||||||
|
|
||||||
{/* Editor / Preview */}
|
{/* Editor / Preview */}
|
||||||
{mode === "edit" ? (
|
{mode === "edit" ? (
|
||||||
<textarea
|
<OverlayScrollbarsComponent
|
||||||
ref={textareaRef}
|
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"
|
||||||
value={draft}
|
options={{ ...OS_OPTIONS, overflow: { x: "hidden" as const } }}
|
||||||
onChange={handleChange}
|
defer
|
||||||
onBlur={handleBlur}
|
>
|
||||||
placeholder="Add a description... (Markdown supported)"
|
<textarea
|
||||||
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"
|
ref={textareaRef}
|
||||||
/>
|
value={draft}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="Add a description... (Markdown supported)"
|
||||||
|
aria-label="Card description (Markdown)"
|
||||||
|
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 +137,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
|||||||
Click to add a description...
|
Click to add a description...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</OverlayScrollbarsComponent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
47
src/components/card-detail/PriorityPicker.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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 })}
|
||||||
|
aria-pressed={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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex h-screen flex-col bg-pylon-bg">
|
<div className="flex h-screen flex-col bg-pylon-bg">
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[200] focus:rounded-md focus:bg-pylon-accent focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white focus:shadow-lg"
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<main className="flex-1 overflow-hidden">{children}</main>
|
<main id="main-content" className="flex-1 overflow-hidden">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,17 +141,126 @@ export function TopBar() {
|
|||||||
|
|
||||||
{/* Right section */}
|
{/* Right section */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{savingStatus && (
|
{isBoardView && (
|
||||||
<span className="mr-2 font-mono text-xs text-pylon-text-secondary">
|
<>
|
||||||
{savingStatus}
|
<AnimatePresence mode="wait">
|
||||||
</span>
|
{savingStatus && (
|
||||||
|
<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}
|
||||||
|
</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"
|
||||||
|
aria-label="Undo"
|
||||||
|
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"
|
||||||
|
aria-label="Redo"
|
||||||
|
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"
|
||||||
|
aria-label="Filter cards"
|
||||||
|
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"
|
||||||
|
aria-label="Board settings"
|
||||||
|
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
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
aria-label="Command palette"
|
||||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
document.dispatchEvent(new CustomEvent("open-command-palette"))
|
document.dispatchEvent(new CustomEvent("open-command-palette"))
|
||||||
@@ -146,6 +280,7 @@ export function TopBar() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
aria-label="Settings"
|
||||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
document.dispatchEvent(new CustomEvent("open-settings-dialog"))
|
document.dispatchEvent(new CustomEvent("open-settings-dialog"))
|
||||||
@@ -156,7 +291,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
@@ -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,60 +34,326 @@ 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 setReduceMotion = useAppStore((s) => s.setReduceMotion);
|
||||||
|
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">
|
||||||
<DialogHeader>
|
<motion.div
|
||||||
<DialogTitle className="font-heading text-pylon-text">
|
animate={{ height: typeof height === "number" && height > 0 ? height : "auto" }}
|
||||||
Settings
|
initial={false}
|
||||||
</DialogTitle>
|
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||||
<DialogDescription className="text-pylon-text-secondary">
|
className="overflow-hidden"
|
||||||
Configure your OpenPylon preferences.
|
>
|
||||||
</DialogDescription>
|
<div ref={contentRef} className="flex flex-col gap-4 p-6">
|
||||||
</DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-heading text-pylon-text">
|
||||||
|
Settings
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-pylon-text-secondary">
|
||||||
|
Configure your OpenPylon preferences.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-5">
|
{/* Tab bar */}
|
||||||
{/* Theme section */}
|
<div className="flex gap-1 border-b border-border pb-2" role="tablist" aria-label="Settings sections">
|
||||||
<div>
|
{TABS.map((t) => (
|
||||||
<label className="mb-2 block font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
|
|
||||||
Theme
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
|
|
||||||
<Button
|
<Button
|
||||||
key={value}
|
key={t.value}
|
||||||
type="button"
|
role="tab"
|
||||||
variant={theme === value ? "default" : "outline"}
|
aria-selected={tab === t.value}
|
||||||
|
variant={tab === t.value ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setTheme(value)}
|
onClick={() => setTab(t.value)}
|
||||||
className="flex-1 gap-2"
|
className="font-mono text-xs"
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
{t.label}
|
||||||
{label}
|
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
{/* Tab content — entire dialog height animates between tabs */}
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={tab}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-label={`${tab} settings`}
|
||||||
|
className="flex flex-col gap-5"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
{tab === "appearance" && (
|
||||||
|
<>
|
||||||
|
{/* Theme */}
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Theme</SectionLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={settings.theme === value}
|
||||||
|
variant={settings.theme === value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
className="flex-1 gap-2"
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* About section */}
|
<Separator />
|
||||||
<div>
|
|
||||||
<label className="mb-2 block font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
|
{/* UI Zoom */}
|
||||||
About
|
<div>
|
||||||
</label>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="space-y-1 text-sm text-pylon-text">
|
<SectionLabel>UI Zoom</SectionLabel>
|
||||||
<p className="font-semibold">OpenPylon v0.1.0</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-pylon-text-secondary">
|
||||||
|
{Math.round(settings.uiZoom * 100)}%
|
||||||
|
</span>
|
||||||
|
{settings.uiZoom !== 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => setUiZoom(1)}
|
||||||
|
aria-label="Reset zoom"
|
||||||
|
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))}
|
||||||
|
aria-label="UI Zoom level"
|
||||||
|
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"
|
||||||
|
aria-pressed={settings.density === value}
|
||||||
|
variant={settings.density === value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDensity(value)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Reduce Motion */}
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Reduce Motion</SectionLabel>
|
||||||
|
<p className="mb-2 text-xs text-pylon-text-secondary">
|
||||||
|
Reduces animations and transitions for accessibility.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{([false, true] as const).map((value) => (
|
||||||
|
<Button
|
||||||
|
key={String(value)}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={settings.reduceMotion === value}
|
||||||
|
variant={settings.reduceMotion === value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setReduceMotion(value)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{value ? "Reduced" : "Normal"}
|
||||||
|
</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"
|
||||||
|
aria-pressed={settings.defaultColumnWidth === value}
|
||||||
|
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
|
v1.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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/toast/ToastContainer.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
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);
|
||||||
|
const removeToast = useToastStore((s) => s.removeToast);
|
||||||
|
const pauseToast = useToastStore((s) => s.pauseToast);
|
||||||
|
const resumeToast = useToastStore((s) => s.resumeToast);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<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 flex items-center gap-2 rounded-lg border px-4 py-2 text-sm shadow-md ${TYPE_STYLES[toast.type]}`}
|
||||||
|
onMouseEnter={() => pauseToast(toast.id)}
|
||||||
|
onMouseLeave={() => resumeToast(toast.id)}
|
||||||
|
onFocus={() => pauseToast(toast.id)}
|
||||||
|
onBlur={() => resumeToast(toast.id)}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70"
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ 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 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/60 dark:focus-visible:ring-destructive/80 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function DialogContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
className="ring-offset-background focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
19
src/hooks/useAnnounce.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface AnnounceState {
|
||||||
|
message: string;
|
||||||
|
announce: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAnnounceStore = create<AnnounceState>((set) => ({
|
||||||
|
message: "",
|
||||||
|
announce: (text) => {
|
||||||
|
// Clear first to ensure re-announcement of identical messages
|
||||||
|
set({ message: "" });
|
||||||
|
requestAnimationFrame(() => set({ message: text }));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function useAnnounce() {
|
||||||
|
return useAnnounceStore((s) => s.announce);
|
||||||
|
}
|
||||||
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);
|
||||||
|
|||||||
110
src/index.css
@@ -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);
|
||||||
@@ -68,11 +70,11 @@
|
|||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.40 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.75 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
@@ -93,58 +95,105 @@
|
|||||||
--pylon-column: oklch(95% 0.008 80);
|
--pylon-column: oklch(95% 0.008 80);
|
||||||
--pylon-accent: oklch(55% 0.12 160);
|
--pylon-accent: oklch(55% 0.12 160);
|
||||||
--pylon-text: oklch(25% 0.015 50);
|
--pylon-text: oklch(25% 0.015 50);
|
||||||
--pylon-text-secondary: oklch(55% 0.01 50);
|
--pylon-text-secondary: oklch(42% 0.01 50);
|
||||||
--pylon-danger: oklch(55% 0.18 25);
|
--pylon-danger: oklch(55% 0.18 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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.75 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 / 25%);
|
||||||
--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(72% 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;
|
||||||
|
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: 3px solid var(--pylon-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* OverlayScrollbars custom theme */
|
||||||
|
.os-theme-pylon {
|
||||||
|
--os-handle-bg: oklch(50% 0 0 / 45%);
|
||||||
|
--os-handle-bg-hover: oklch(50% 0 0 / 60%);
|
||||||
|
--os-handle-bg-active: oklch(50% 0 0 / 75%);
|
||||||
|
--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 / 35%);
|
||||||
|
--os-handle-bg-hover: oklch(80% 0 0 / 55%);
|
||||||
|
--os-handle-bg-active: oklch(80% 0 0 / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
:root {
|
||||||
|
--pylon-text: oklch(10% 0.02 50);
|
||||||
|
--pylon-text-secondary: oklch(30% 0.01 50);
|
||||||
|
--muted-foreground: oklch(0.30 0 0);
|
||||||
|
--border: oklch(0.55 0 0);
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--pylon-text-secondary: oklch(85% 0.01 50);
|
||||||
|
--muted-foreground: oklch(0.85 0 0);
|
||||||
|
--border: oklch(1 0 0 / 50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,3 +207,12 @@
|
|||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reduce-motion *,
|
||||||
|
.reduce-motion *::before,
|
||||||
|
.reduce-motion *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,28 @@ 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),
|
||||||
|
reduceMotion: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,17 @@ 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;
|
||||||
|
setReduceMotion: (reduceMotion: boolean) => 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 +37,46 @@ function applyTheme(theme: AppSettings["theme"]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyReduceMotion(on: boolean): void {
|
||||||
|
document.documentElement.classList.toggle("reduce-motion", on);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
reduceMotion: false,
|
||||||
|
},
|
||||||
boards: [],
|
boards: [],
|
||||||
view: { type: "board-list" },
|
view: { type: "board-list" },
|
||||||
initialized: false,
|
initialized: false,
|
||||||
@@ -40,13 +87,77 @@ 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);
|
||||||
|
applyReduceMotion(settings.reduceMotion);
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
},
|
||||||
|
|
||||||
|
setReduceMotion: (reduceMotion) => {
|
||||||
|
updateAndSave(get, set, { reduceMotion });
|
||||||
|
applyReduceMotion(reduceMotion);
|
||||||
},
|
},
|
||||||
|
|
||||||
setView: (view) => set({ view }),
|
setView: (view) => set({ view }),
|
||||||
@@ -62,8 +173,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,9 +80,14 @@ function debouncedSave(
|
|||||||
set({ saving: true });
|
set({ saving: true });
|
||||||
try {
|
try {
|
||||||
await saveBoard(board);
|
await saveBoard(board);
|
||||||
set({ saving: false, lastSaved: Date.now() });
|
// Only update state if the same board is still loaded
|
||||||
|
if (get().board?.id === board.id) {
|
||||||
|
set({ saving: false, lastSaved: Date.now() });
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
set({ saving: false });
|
if (get().board?.id === board.id) {
|
||||||
|
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) => {
|
||||||
|
|||||||
73
src/stores/toast-store.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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;
|
||||||
|
pauseToast: (id: string) => void;
|
||||||
|
resumeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const remaining = new Map<string, number>();
|
||||||
|
const startTimes = new Map<string, number>();
|
||||||
|
|
||||||
|
const TOAST_DURATION = 8000;
|
||||||
|
|
||||||
|
function startTimer(id: string, duration: number, set: (fn: (s: ToastState) => Partial<ToastState>) => void) {
|
||||||
|
startTimes.set(id, Date.now());
|
||||||
|
remaining.set(id, duration);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||||
|
timers.delete(id);
|
||||||
|
remaining.delete(id);
|
||||||
|
startTimes.delete(id);
|
||||||
|
}, duration);
|
||||||
|
timers.set(id, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
addToast: (message, type = "info") => {
|
||||||
|
const id = String(++nextId);
|
||||||
|
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
|
||||||
|
startTimer(id, TOAST_DURATION, set);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeToast: (id) => {
|
||||||
|
const timer = timers.get(id);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
remaining.delete(id);
|
||||||
|
startTimes.delete(id);
|
||||||
|
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||||
|
},
|
||||||
|
|
||||||
|
pauseToast: (id) => {
|
||||||
|
const timer = timers.get(id);
|
||||||
|
const start = startTimes.get(id);
|
||||||
|
const rem = remaining.get(id);
|
||||||
|
if (timer && start != null && rem != null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
remaining.set(id, rem - (Date.now() - start));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resumeToast: (id) => {
|
||||||
|
const rem = remaining.get(id);
|
||||||
|
if (rem != null && rem > 0) {
|
||||||
|
startTimer(id, rem, set);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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,26 @@
|
|||||||
|
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;
|
||||||
|
reduceMotion: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||