diff --git a/DESIGN.md b/docs/CRYPTO + API.md similarity index 100% rename from DESIGN.md rename to docs/CRYPTO + API.md diff --git a/docs/DESIGN + UX.md b/docs/DESIGN + UX.md new file mode 100644 index 0000000..4893992 --- /dev/null +++ b/docs/DESIGN + UX.md @@ -0,0 +1,355 @@ +# Design System & UX Approach + +## Overview + +This project follows a **minimalist, technical Proton-like design philosophy** with a focus on clarity, consistency, and efficiency. The design system is centralized in CSS variables and reusable components to ensure a unified experience across all applications. + +--- + +## Design System + +### Color Palette + +#### Primary Colors +- **Primary Purple**: `#6d4aff` - Main brand color for primary actions and accents +- **Primary Hover**: `#5836e8` - Hover state for primary elements +- **Primary Active**: `#4a2bc4` - Active/pressed state +- **Primary Light**: `#f5f3ff` - Light background for selected/active states + +#### Semantic Colors +- **Danger Red**: `#dc2626` - Destructive actions +- **Danger Hover**: `#b91c1c` - Hover state for danger actions +- **Danger Light**: `#fef2f2` - Light background for danger states +- **Success Green**: `#059669` - Success indicators +- **Warning Yellow**: `#fbbf24` - Warning indicators + +#### Neutral Colors +- **Text Primary**: `#1c1f23` - Main text color +- **Text Secondary**: `#464c52` - Secondary text +- **Text Tertiary**: `#70757a` - Tertiary/muted text +- **Text Disabled**: `#9ca3af` - Disabled state text +- **Border**: `#dde1e6` - Default borders +- **Border Light**: `#f0f1f3` - Light borders +- **Border Dark**: `#c1c7cd` - Emphasized borders +- **Background**: `#f8f9fa` - Main background +- **Background White**: `#ffffff` - Card/panel background +- **Background Hover**: `#f5f6f8` - Hover states + +### Border Radius + +Following a subtle, technical aesthetic: +- **Small (4px)**: Cards, buttons, inputs, small elements +- **Medium (6px)**: Larger containers, columns, panels +- **Large (10px)**: Modal dialogs, major sections +- **Pill (12px)**: Badges, tags, counters + +**Principle**: Prefer `4px` for most elements. Use `6px` only for larger structural components. + +### Spacing Scale + +Consistent 4px-based spacing system: +- **XS**: `4px` - Minimal gaps +- **SM**: `8px` - Tight spacing +- **MD**: `12px` - Default spacing +- **LG**: `16px` - Comfortable spacing +- **XL**: `20px` - Generous spacing +- **2XL**: `24px` - Section spacing + +### Shadows + +Subtle elevation system: +- **Small**: `0 1px 2px rgba(0, 0, 0, 0.05)` - Minimal lift +- **Medium**: `0 1px 3px rgba(0, 0, 0, 0.1)` - Default cards +- **Large**: `0 2px 8px rgba(0, 0, 0, 0.15)` - Elevated elements, dropdowns + +### Typography + +- **Font Family**: System fonts for optimal performance and native feel +- **Sizes**: + - Headers: `1.75rem` (28px) for h1, `1.5rem` (24px) for h2 + - Body: `0.9375rem` (15px) for main content + - Small: `0.875rem` (14px) for secondary text + - Tiny: `0.8125rem` (13px) for tertiary text + +### Transitions + +- **Fast**: `0.15s` - Hover effects, subtle changes +- **Normal**: `0.2s` - Default transitions + +--- + +## UX Approach + +### Information Architecture + +All four applications (Notes, Journal, Lists, Boards) follow a consistent **three-panel layout**: + +``` +┌─────────────┬──────────────┬─────────────────┐ +│ Sidebar │ Middle │ Main Content │ +│ (Folders/ │ (Items/ │ (Detail View/ │ +│ Collections)│ Notes/ │ Editor) │ +│ │ Entries) │ │ +└─────────────┴──────────────┴─────────────────┘ +``` + +#### Panel Hierarchy + +1. **Left Sidebar** (280px) + - Top-level collections (Folders, Journals, Lists, Boards) + - Global actions (Export, Import) + - View filters (Show archived) + +2. **Middle Panel** (flexible, ~300-400px) + - Individual items within selected collection + - Quick preview information + - Creation of new items + +3. **Right Panel** (main, flexible) + - Full detail view and editing interface + - Primary actions contextual to selected item + +### Action Hierarchy + +#### Primary Actions +**Principle**: Most-used, most-important actions are always visible as buttons. + +**Examples**: +- `+ New List` / `+ New Note` / `+ New Board` +- `Edit` (when viewing a note/entry) +- `Save` / `Cancel` (when editing) +- `📌 Pin` (frequent action in Notes) + +**Style**: +- Solid background with primary color +- Clear label (icon optional) +- Prominent placement in header/toolbar + +#### Secondary Actions +**Principle**: Less-frequent but important actions are grouped in a dropdown menu (⋯). + +**Examples**: +- Rename +- Archive/Unarchive +- Validate (Journal-specific) + +**Style**: +- Icon-only button "⋯" that opens dropdown +- Menu items with icon + label +- Hover states for discoverability + +#### Destructive Actions +**Principle**: Dangerous actions (Delete) are separated and styled distinctly. + +**Location**: Always at the bottom of dropdown menus, separated by a divider line + +**Style**: +- Red text color (`--color-danger`) +- Red background on hover (`--color-danger-light`) +- Confirmation dialog before execution + +### Interaction Patterns + +#### Inline Editing +- Click on title/name to edit in-place +- `Enter` to save, `Escape` to cancel +- Auto-focus on edit input +- Visual indication (border color change to primary) + +#### Contextual Actions +- Actions appear based on current selection and state +- Hover-triggered actions (e.g., delete button on list items) +- State-dependent labels (Archive ↔ Unarchive, Pin ↔ Unpin) + +#### Keyboard Navigation +- `Enter` to confirm/save in all input fields +- `Escape` to cancel editing/close dropdowns +- Natural tab order for accessibility + +#### Empty States +- Friendly, centered messages when no content exists +- Large emoji icon for visual interest +- Call-to-action hint (e.g., "Create one!") + +### Visual Feedback + +#### Loading States +- Disabled buttons during save operations +- "Saving..." text for async operations +- Prevent double-submissions + +#### Error States +- Prominent error message banner (red background) +- Clear error text describing the issue +- Automatic dismissal or close button + +#### Success Indicators +- Smooth transitions when items are created/updated +- Items appear/update without page refresh +- Sort order updates reflect latest changes + +### State Management + +#### Active States +- Selected items highlighted with light primary background (`--color-bg-active`) +- Clear visual distinction from hover states +- Maintains context across panel views + +#### Archived Items +- Reduced opacity (50%) for archived items +- "Show archived" toggle in filters +- Archive badge/label in headers when viewing archived item + +#### Pinned Items (Notes) +- Appear at top of list +- Pin indicator in item preview +- Toggle on/off in editor + +### Responsive Behavior + +#### Panel Collapse (Future Enhancement) +For smaller screens: +1. Hide left sidebar, show hamburger menu +2. Middle panel becomes full-width item list +3. Detail view replaces list when item selected + +#### Touch Targets +- Minimum 32px height for all interactive elements +- Adequate spacing between clickable items +- No hover-only actions (all accessible via tap/click) + +--- + +## Component Library + +### DropdownMenu +**Purpose**: Consistent menu component for secondary actions + +**Features**: +- Icon + label menu items +- Danger variant for destructive actions +- Separator lines for grouping +- Click-outside and Escape-key dismissal +- Left/right alignment options + +**Usage**: +```tsx + {} }, + { label: 'Delete', icon: '🗑️', onClick: handleDelete, variant: 'danger' }, + ]} +/> +``` + +### Button Variants +Defined in `design-system.css`: +- `.btn-primary` - Main actions (purple background) +- `.btn-secondary` - Alternative actions (gray background) +- `.btn-danger` - Destructive actions (red background) +- `.btn-icon` - Icon-only buttons (32x32px) +- `.btn-small` - Compact buttons for dense UIs + +### Form Inputs +- Consistent padding: `8px 12px` +- Border color: `--color-border` (default), `--color-primary` (focus) +- Border radius: `--radius-sm` (4px) +- Transitions on focus for smooth interaction + +### Badges & Labels +- Small, rounded indicators +- Semantic colors (archived, pinned, etc.) +- Fixed padding for consistent sizing + +--- + +## Design Principles + +### 1. **Consistency First** +Every application follows the same patterns: +- Same layout structure +- Same action placement +- Same visual language +- Same interaction behaviors + +### 2. **Progressive Disclosure** +- Show most important information and actions by default +- Hide less-frequent actions in organized menus +- Expand details on demand (e.g., card descriptions) + +### 3. **Minimize Cognitive Load** +- Clear visual hierarchy +- Familiar interaction patterns +- Predictable behavior across apps +- Minimal clicks to complete tasks + +### 4. **Technical Aesthetic** +- Subtle, not flashy +- Functional, not decorative +- Precise, not rounded +- Clean, not cluttered + +### 5. **Accessibility & Performance** +- Semantic HTML +- Keyboard navigation support +- High contrast ratios +- Fast, lightweight CSS + +--- + +## Implementation Guidelines + +### Adding New Features + +1. **Check Design System First**: Use existing CSS variables and components +2. **Follow Layout Patterns**: Maintain three-panel structure +3. **Use DropdownMenu**: For any secondary/tertiary actions +4. **Apply Spacing Scale**: Use `--spacing-*` variables +5. **Match Existing Patterns**: Look at similar features in other apps + +### Modifying Styles + +1. **Update Variables**: Change `design-system.css` for global updates +2. **Component-Specific**: Add styles only for unique component needs +3. **Avoid Magic Numbers**: Always use design system values +4. **Test Across Apps**: Ensure changes work in all four applications + +### Creating New Components + +1. **Evaluate Reusability**: Can this be used across multiple apps? +2. **Follow Naming**: Use BEM-like class naming (`.component-name-element`) +3. **Use CSS Variables**: Reference design system colors, spacing, etc. +4. **Document Usage**: Add examples and guidelines + +--- + +## Future Enhancements + +### Planned Improvements +- [ ] Dark mode support (duplicate CSS variables with dark theme) +- [ ] Mobile responsive layouts (panel collapse) +- [ ] Enhanced accessibility (ARIA labels, screen reader support) +- [ ] Animation library (consistent micro-interactions) +- [ ] Icon system (replace emoji with SVG icons) + +### Consideration Areas +- Keyboard shortcuts documentation +- Undo/redo functionality UI +- Bulk actions (select multiple items) +- Drag-and-drop visual feedback enhancement +- Real-time collaboration indicators + +--- + +## Conclusion + +This design system provides a solid foundation for consistent, usable, and maintainable applications. By centralizing design decisions and following established patterns, we ensure that: + +- New features integrate seamlessly +- Users have a predictable experience +- Code remains clean and DRY +- Future updates are easier to implement + +For questions or suggestions, refer to this document and existing implementations in Notes, Journal, Lists, and Boards. diff --git a/docs/UX-GUIDELINES.md b/docs/UX-GUIDELINES.md new file mode 100644 index 0000000..f8f6184 --- /dev/null +++ b/docs/UX-GUIDELINES.md @@ -0,0 +1,282 @@ +# UX Guidelines for Cryptd Applications + +This document defines consistent UX patterns across all Cryptd applications (Notes, Lists, Journal, Boards). + +## Version +**Version:** 1.0 +**Last Updated:** January 29, 2026 + +--- + +## Table of Contents +1. [Button Consistency](#button-consistency) +2. [Naming Conventions](#naming-conventions) +3. [Archive Functionality](#archive-functionality) +4. [Export/Import Actions](#exportimport-actions) +5. [View Filters](#view-filters) +6. [Empty States](#empty-states) +7. [Form Actions](#form-actions) +8. [Icon Usage](#icon-usage) + +--- + +## 1. Button Consistency + +### 1.1 Button Heights +All buttons of the same category **must** have identical padding for consistent heights. + +**Primary Action Buttons** (Save, Add Entry, etc.): +```css +padding: 9px 18px; +``` + +**Secondary Action Buttons** (Cancel, Pin, Archive, etc.): +```css +padding: 9px 18px; +``` + +**Small Buttons** (in forms): +```css +padding: 6px 12px; +``` + +**Icon-only Buttons**: +```css +width: 32px; +height: 32px; +padding: 0; +``` + +### 1.2 Button Classes +- `.btn-primary` - Main actions (Save, Add, Create) +- `.btn-secondary` - Alternative actions (Pin, Archive) +- `.btn-danger` - Destructive actions (Delete) +- `.btn-icon` - Icon-only buttons for non-destructive actions +- `.btn-icon-danger` - Icon-only buttons for destructive actions +- `.btn-save-small` - Small save buttons in inline forms +- `.btn-cancel-small` - Small cancel buttons in inline forms + +--- + +## 2. Naming Conventions + +### 2.1 "New" Button Labels +All "New" buttons should follow the pattern: `+ New [ItemType]` + +**Required format:** +- Notes: `+ New Folder` +- Lists: `+ New List` +- Journal: `+ New Journal` +- Boards: `+ New Board` + +**When creating items within a context:** +- Notes: `+ New Note` (inside a folder) +- Lists: `+ New Item` (inside a list) +- Journal: `Add Entry` (inside a journal - button style) +- Boards: `+ Add column` (when adding columns), `+ Add a card` (when adding cards) + +### 2.2 Item Naming +Use consistent terminology: +- **Container Level**: Folder, List, Journal, Board +- **Item Level**: Note, Item, Entry, Card/Column + +--- + +## 3. Archive Functionality + +### 3.1 Availability +**All applications** must support archiving at both container and item levels: +- **Notes**: Folders and Notes can be archived +- **Lists**: Lists and Items can be archived +- **Journal**: Journals and Entries can be archived +- **Boards**: Boards and Columns/Cards can be archived + +### 3.2 Archive UI Elements + +**Container Level (Folder/List/Journal/Board):** +- Icon button with emoji: 📦 (Archive) or 📂 (Unarchive) +- Button title attribute: "Archive [type]" or "Unarchive [type]" +- Located in header actions next to Edit/Delete + +**Item Level:** +- Button/action in item actions menu +- Label: "Archive" or "Unarchive" + +### 3.3 Archive Badge +Archived items show: +- Badge emoji: 📦 +- Reduced opacity: `opacity: 0.6` +- Label (when expanded): "Archived" or "📦 Archived" + +--- + +## 4. Export/Import Actions + +### 4.1 Button Layout +Export and Import buttons should: +- Be placed in sidebar under the main header +- Use a horizontal flex layout with equal width +- Include emoji icons for visual clarity + +### 4.2 Button Style +``` +📥 Export | 📤 Import +``` + +**CSS Classes:** +- `.btn-export` for Export button +- `.btn-import` for Import button + +**Emoji prefix:** +- Export: 📥 +- Import: 📤 + +### 4.3 Button Text +Always include text label with emoji: +- "📥 Export" (not just "Export") +- "📤 Import" (not just "Import") + +--- + +## 5. View Filters + +### 5.1 "Show archived" Checkbox +**All applications** must include a "Show archived" checkbox filter. + +**Location:** In sidebar, below Export/Import actions, above the items list. + +**Structure:** +```html +
+ +
+``` + +### 5.2 Additional Filters +Applications may have additional app-specific filters (e.g., Lists has "Show checked"). + +All filters should follow the same checkbox pattern and be grouped in the `.view-filters` section. + +--- + +## 6. Empty States + +### 6.1 Empty State Messages +Use consistent, helpful messages: + +**No containers:** +- "No [containers] yet. Create one!" +- Example: "No folders yet. Create one!" + +**No active containers (when filtered):** +- "No active [containers]. Create one!" + +**With archive filter on:** +- "No [containers] yet." + +**No items in container:** +- "No [items] in this [container]." +- Example: "No notes in this folder." + +### 6.2 Empty State Icons +Use relevant emoji icons: +- Notes: 📝 +- Lists: ✅ +- Journal: 📔 (sidebar), ✍️ (entries) +- Boards: 📋 + +--- + +## 7. Form Actions + +### 7.1 Inline Create Forms +When creating new containers (folder, list, journal, board): + +1. Input field appears inline +2. Two buttons below: "Create" and "Cancel" +3. Enter key triggers save +4. Escape key triggers cancel + +### 7.2 Button Labels +- Create mode: "Create" (not "Save") +- Edit mode: "Save" +- Always pair with "Cancel" + +### 7.3 Button Layout +```html +
+ + +
+``` + +--- + +## 8. Icon Usage + +### 8.1 Standard Icons +Use these emoji icons consistently: + +**Actions:** +- Edit/Rename: ✏️ +- Delete: 🗑️ +- Archive: 📦 +- Unarchive: 📂 +- Pin: 📌 +- Export: 📥 +- Import: 📤 + +**Item States:** +- Pinned: 📌 +- Archived: 📦 +- Checked/Complete: ✅ +- Calendar/Date: 📅 + +**Application Icons:** +- Notes: 📝 +- Lists: ✅ +- Journal: 📔, ✍️ +- Boards: 📋 + +### 8.2 Icon Button Accessibility +All icon-only buttons must have `title` attributes for accessibility: +```html + +``` + +--- + +## Implementation Checklist + +When creating or updating an application, ensure: + +- [ ] "New" button follows `+ New [ItemType]` pattern +- [ ] Export/Import buttons have emoji icons +- [ ] Archive functionality exists for containers and items +- [ ] "Show archived" checkbox is present +- [ ] All buttons of same type have consistent heights +- [ ] Icon buttons have title attributes +- [ ] Empty states use standard messages +- [ ] Form action buttons use "Create"/"Save"/"Cancel" labels + +--- + +## Notes on Application-Specific Features + +While consistency is key, some applications may have unique features: + +- **Journal**: Daily mode, month grouping (application-specific) +- **Boards**: Drag-and-drop, columns (application-specific) +- **Lists**: Item checking, checked/unchecked filter (application-specific) +- **Notes**: Pinning, folder-note hierarchy (application-specific) + +These features are acceptable as long as they follow the core UX principles outlined above. diff --git a/web/src/App.css b/web/src/App.css index 75fd203..83a045b 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,32 +1,20 @@ -* { - box-sizing: border-box; -} - -html, body, #root { - margin: 0; - padding: 0; - height: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - +/* App Layout */ .app-layout { display: flex; flex-direction: column; height: 100vh; + background: var(--color-bg); } -/* Navigation */ +/* Navigation Bar */ .app-nav { display: flex; align-items: center; justify-content: space-between; - padding: 12px 20px; - background: #ffffff; - border-bottom: 1px solid #dde1e6; + padding: var(--spacing-md) var(--spacing-xl); + background: var(--color-bg-white); + border-bottom: 1px solid var(--color-border); + min-height: 64px; } .nav-brand { @@ -35,7 +23,6 @@ html, body, #root { gap: 10px; font-weight: 600; font-size: 1.25rem; - color: #1c1f23; } .nav-logo { @@ -43,65 +30,65 @@ html, body, #root { } .nav-title { - color: #6d4aff; + color: var(--color-primary); } .nav-links { display: flex; - gap: 4px; + gap: var(--spacing-xs); } .nav-link { - padding: 8px 16px; - border-radius: 4px; + padding: var(--spacing-sm) var(--spacing-lg); + border-radius: var(--radius-sm); text-decoration: none; - color: #464c52; + color: var(--color-text-secondary); font-weight: 500; - font-size: 0.9375rem; - transition: background 0.15s, color 0.15s; + font-size: var(--font-size-base); + transition: background var(--transition-fast), color var(--transition-fast); } .nav-link:hover { - background: #f5f6f8; - color: #1c1f23; + background: var(--color-bg-hover); + color: var(--color-text-primary); } .nav-link.active { - background: #6d4aff; - color: #ffffff; + background: var(--color-primary); + color: var(--color-bg-white); } .nav-user { display: flex; align-items: center; - gap: 12px; + gap: var(--spacing-md); } .username { - color: #464c52; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); font-weight: 400; - font-size: 0.875rem; } .btn-logout { padding: 7px 14px; - border: 1px solid #c1c7cd; - border-radius: 4px; - background: #ffffff; - color: #464c52; - font-size: 0.875rem; + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + background: var(--color-bg-white); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); font-weight: 500; cursor: pointer; - transition: border-color 0.15s, background 0.15s, color 0.15s; + transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast); } .btn-logout:hover { - border-color: #dc2626; - color: #dc2626; - background: #fef2f2; + border-color: var(--color-danger); + color: var(--color-danger); + background: var(--color-danger-light); } -/* Main Content */ +/* Main Content Area */ .app-main { flex: 1; overflow: hidden; @@ -111,7 +98,7 @@ html, body, #root { @media (max-width: 768px) { .app-nav { flex-wrap: wrap; - gap: 12px; + gap: var(--spacing-md); } .nav-brand { diff --git a/web/src/App.tsx b/web/src/App.tsx index 64ce023..f063bfd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom'; import Auth from './components/Auth'; import Notes from './components/Notes'; +import Lists from './components/Lists'; import Journals from './components/Journals'; +import Boards from './components/Boards'; import { isAuthenticated, clearAuthState, getUsername } from './lib/auth'; import { onSessionExpired } from './lib/api'; import { startSessionMonitoring, stopSessionMonitoring } from './lib/sessionManager'; @@ -54,12 +56,24 @@ function Layout({ children }: { children: React.ReactNode }) { > 📝 Notes + + ✅ Lists + 📔 Journals + + 📋 Boards +
@@ -114,6 +128,15 @@ function App() { } /> + + + + } + /> + } /> + + + + + } + /> } /> diff --git a/web/src/components/Auth.css b/web/src/components/Auth.css index 7552959..1abe2a8 100644 --- a/web/src/components/Auth.css +++ b/web/src/components/Auth.css @@ -3,127 +3,130 @@ display: flex; align-items: center; justify-content: center; - background: #f8f9fa; - padding: 20px; + background: var(--color-bg); + padding: var(--spacing-xl); } .auth-card { - background: #ffffff; - border: 1px solid #dde1e6; - border-radius: 8px; + background: var(--color-bg-white); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); padding: 40px; width: 100%; max-width: 420px; + box-shadow: var(--shadow-md); } .auth-card h1 { - margin: 0 0 8px 0; + margin: 0 0 var(--spacing-sm) 0; font-size: 2rem; font-weight: 600; text-align: center; - color: #1c1f23; + color: var(--color-text-primary); } .auth-card .subtitle { margin: 0 0 32px 0; text-align: center; - color: #70757a; - font-size: 0.9375rem; + color: var(--color-text-tertiary); + font-size: var(--font-size-base); } .auth-tabs { display: flex; gap: 0; - margin-bottom: 24px; - border-bottom: 1px solid #dde1e6; + margin-bottom: var(--spacing-2xl); + border-bottom: 1px solid var(--color-border); } .auth-tabs button { flex: 1; - padding: 12px; + padding: var(--spacing-md); border: none; background: none; - color: #70757a; - font-size: 0.9375rem; + color: var(--color-text-tertiary); + font-size: var(--font-size-base); font-weight: 500; cursor: pointer; position: relative; - transition: color 0.15s; + transition: color var(--transition-fast); border-bottom: 2px solid transparent; margin-bottom: -1px; } .auth-tabs button:hover { - color: #464c52; + color: var(--color-text-secondary); } .auth-tabs button.active { - color: #6d4aff; - border-bottom-color: #6d4aff; + color: var(--color-primary); + border-bottom-color: var(--color-primary); } .form-group { - margin-bottom: 20px; + margin-bottom: var(--spacing-xl); } .form-group label { display: block; - margin-bottom: 8px; - color: #1c1f23; + margin-bottom: var(--spacing-sm); + color: var(--color-text-primary); font-weight: 500; - font-size: 0.875rem; + font-size: var(--font-size-sm); } .form-group input { width: 100%; - padding: 10px 12px; - border: 1px solid #c1c7cd; - border-radius: 4px; - font-size: 0.9375rem; - transition: border-color 0.15s; + padding: 10px var(--spacing-md); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + transition: border-color var(--transition-fast); box-sizing: border-box; - background: #ffffff; - color: #1c1f23; + background: var(--color-bg-white); + color: var(--color-text-primary); + font-family: var(--font-family); } .form-group input:focus { outline: none; - border-color: #6d4aff; + border-color: var(--color-primary); } .form-group input::placeholder { - color: #9ca3af; + color: var(--color-text-disabled); } .error-message { - padding: 12px 14px; - background: #fef2f2; + padding: var(--spacing-md) 14px; + background: var(--color-danger-light); border: 1px solid #fecaca; - border-radius: 4px; - color: #dc2626; - margin-bottom: 20px; - font-size: 0.875rem; + border-radius: var(--radius-sm); + color: var(--color-danger); + margin-bottom: var(--spacing-xl); + font-size: var(--font-size-sm); } .submit-button { width: 100%; - padding: 11px 16px; + padding: 11px var(--spacing-lg); border: none; - border-radius: 4px; - background: #6d4aff; - color: #ffffff; - font-size: 0.9375rem; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-bg-white); + font-size: var(--font-size-base); font-weight: 500; cursor: pointer; - transition: background 0.15s; + transition: background var(--transition-fast); + font-family: var(--font-family); } .submit-button:hover:not(:disabled) { - background: #5836e8; + background: var(--color-primary-hover); } .submit-button:active:not(:disabled) { - background: #4a2bc4; + background: var(--color-primary-active); } .submit-button:disabled { @@ -132,17 +135,17 @@ } .info-box { - margin-top: 24px; - padding: 16px; - background: #f8f9fa; - border: 1px solid #dde1e6; - border-radius: 4px; - font-size: 0.8125rem; - color: #464c52; + margin-top: var(--spacing-2xl); + padding: var(--spacing-lg); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); } .info-box p { - margin: 0 0 8px 0; + margin: 0 0 var(--spacing-sm) 0; line-height: 1.5; } @@ -151,6 +154,6 @@ } .info-box strong { - color: #1c1f23; + color: var(--color-text-primary); font-weight: 500; } diff --git a/web/src/components/Boards.css b/web/src/components/Boards.css new file mode 100644 index 0000000..c4cc8e0 --- /dev/null +++ b/web/src/components/Boards.css @@ -0,0 +1,801 @@ +.boards-container { + display: flex; + height: 100%; + background: var(--color-bg); +} + +/* === LEFT SIDEBAR - BOARDS === */ +.boards-sidebar { + width: 280px; + background: var(--color-bg-white); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.btn-new { + padding: var(--spacing-sm) 14px; + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-bg-white); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-new:hover { + background: var(--color-primary-hover); +} + +.sidebar-actions { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + display: flex; + gap: var(--spacing-sm); +} + +.btn-export, +.btn-import { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + background: var(--color-bg-white); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 400; + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.btn-export:hover, +.btn-import:hover { + border-color: var(--color-primary); + background: var(--color-primary-light); +} + +.view-filters { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.filter-checkbox { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; +} + +.filter-checkbox input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; + accent-color: var(--color-primary); +} + +.new-board-form { + padding: var(--spacing-sm) var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.board-title-input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-primary); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + outline: none; + background: var(--color-bg-white); + margin-bottom: var(--spacing-sm); + font-family: var(--font-family); +} + +.form-actions { + display: flex; + gap: var(--spacing-sm); +} + +.btn-save-small, +.btn-cancel-small { + flex: 1; + padding: 6px var(--spacing-md); + border: none; + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-save-small { + background: var(--color-primary); + color: var(--color-bg-white); +} + +.btn-save-small:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-save-small:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-cancel-small { + background: #e5e7eb; + color: var(--color-text-secondary); +} + +.btn-cancel-small:hover:not(:disabled) { + background: #d1d5db; +} + +.boards-list { + flex: 1; + overflow-y: auto; +} + +.board-item { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; + transition: background var(--transition-fast); +} + +.board-item:hover { + background: var(--color-bg-hover); +} + +.board-item.active { + background: var(--color-bg-active); + border-left: 3px solid var(--color-primary); +} + +.board-item.archived { + opacity: 0.5; +} + +.board-title { + font-weight: 500; + color: var(--color-text-primary); + font-size: var(--font-size-base); + margin-bottom: var(--spacing-xs); + display: flex; + align-items: center; + gap: 6px; +} + +.archived-badge { + font-size: 0.75rem; +} + +.board-stats { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); +} + +/* === MAIN BOARD CONTENT === */ +.board-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--color-bg); +} + +.board-header { + padding: var(--spacing-xl) 32px; + background: var(--color-bg-white); + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.board-header-info { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.board-header h1 { + margin: 0; + font-size: var(--font-size-2xl); + font-weight: 600; + color: var(--color-text-primary); +} + +.board-archived-label { + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--color-warning); + color: var(--color-bg-white); + font-size: 0.75rem; + font-weight: 600; + border-radius: var(--radius-sm); +} + +.board-actions { + display: flex; + gap: var(--spacing-sm); +} + +.btn-icon, +.btn-icon-danger { + width: 32px; + height: 32px; + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + background: var(--color-bg-white); + font-size: 1rem; + cursor: pointer; + transition: all var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.btn-icon:hover { + background: var(--color-bg-hover); + border-color: var(--color-primary); +} + +.btn-icon-danger:hover { + background: var(--color-danger); + border-color: var(--color-danger); + color: var(--color-bg-white); +} + +.rename-board-form { + display: flex; + align-items: center; + gap: var(--spacing-md); + flex: 1; +} + +.rename-board-input { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-primary); + border-radius: var(--radius-sm); + font-size: 1.25rem; + font-weight: 600; + outline: none; + background: var(--color-bg-white); + font-family: var(--font-family); +} + +.rename-actions { + display: flex; + gap: var(--spacing-sm); +} + +.btn-save-rename, +.btn-cancel-rename { + padding: var(--spacing-sm) var(--spacing-lg); + border: none; + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-save-rename { + background: var(--color-primary); + color: var(--color-bg-white); +} + +.btn-save-rename:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-save-rename:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-cancel-rename { + background: #e5e7eb; + color: var(--color-text-primary); +} + +.btn-cancel-rename:hover:not(:disabled) { + background: #d1d5db; +} + +/* === BOARD COLUMNS CONTAINER === */ +.board-columns-container { + flex: 1; + overflow-x: auto; + overflow-y: hidden; + padding: var(--spacing-2xl); +} + +.board-columns { + display: flex; + gap: var(--spacing-lg); + min-height: 100%; + align-items: flex-start; +} + +.board-column { + flex-shrink: 0; + width: 280px; + background: var(--color-bg-white); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + display: flex; + flex-direction: column; + max-height: calc(100vh - 150px); + transition: box-shadow var(--transition-normal); +} + +.board-column.drag-over { + box-shadow: var(--shadow-lg); + border: 2px solid var(--color-primary); +} + +.board-column.archived { + opacity: 0.6; +} + +.column-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); +} + +.column-title { + margin: 0; + font-size: var(--font-size-base); + font-weight: 600; + color: var(--color-text-primary); + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex: 1; +} + +.archived-badge { + font-size: 0.75rem; + flex-shrink: 0; +} + +.card-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 6px; + background: var(--color-bg); + color: var(--color-text-tertiary); + font-size: 0.75rem; + font-weight: 600; + border-radius: var(--radius-pill); +} + +.column-actions { + display: flex; + gap: var(--spacing-xs); +} + +.btn-column-action { + width: 24px; + height: 24px; + border: none; + background: transparent; + font-size: var(--font-size-sm); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.btn-column-action:hover { + background: var(--color-bg-hover); +} + +.column-edit-form { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex: 1; +} + +.column-edit-input { + flex: 1; + padding: 6px var(--spacing-sm); + border: 1px solid var(--color-primary); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-weight: 600; + outline: none; + background: var(--color-bg-white); + font-family: var(--font-family); +} + +.column-edit-actions { + display: flex; + gap: var(--spacing-xs); +} + +/* === COLUMN CARDS === */ +.column-cards { + flex: 1; + overflow-y: auto; + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.board-card { + padding: var(--spacing-md); + background: var(--color-bg); + border-radius: var(--radius-sm); + cursor: grab; + transition: all var(--transition-fast); + border: 1px solid transparent; +} + +.board-card:hover { + background: #e8e8ed; + box-shadow: var(--shadow-md); +} + +.board-card:active { + cursor: grabbing; +} + +.board-card.editing { + cursor: default; + background: var(--color-bg-white); + border: 1px solid var(--color-primary); + box-shadow: var(--shadow-md); +} + +.board-card.expanded { + background: var(--color-bg-white); + box-shadow: var(--shadow-md); +} + +.board-card.archived { + opacity: 0.6; +} + +.card-content { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + cursor: pointer; + margin-bottom: var(--spacing-sm); +} + +.card-title { + font-size: var(--font-size-base); + font-weight: 500; + color: var(--color-text-primary); + line-height: 1.4; + word-wrap: break-word; + display: flex; + align-items: center; + gap: 6px; +} + +.card-description { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); + line-height: 1.4; + word-wrap: break-word; + max-height: 40px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.card-description.expanded { + max-height: none; + -webkit-line-clamp: unset; +} + +.card-actions { + display: flex; + gap: var(--spacing-xs); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.board-card:hover .card-actions { + opacity: 1; +} + +.btn-card-action { + width: 24px; + height: 24px; + border: none; + background: var(--color-bg-white); + font-size: var(--font-size-sm); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + box-shadow: var(--shadow-sm); +} + +.btn-card-action:hover { + background: #e5e5ea; +} + +/* === CARD EDITING === */ +.card-edit-form { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.card-edit-title { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-primary); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + font-weight: 500; + outline: none; + background: var(--color-bg-white); + font-family: var(--font-family); +} + +.card-edit-content { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + outline: none; + background: var(--color-bg-white); + font-family: var(--font-family); + resize: vertical; + min-height: 60px; +} + +.card-edit-content:focus { + border-color: var(--color-primary); +} + +.card-edit-actions { + display: flex; + gap: var(--spacing-sm); +} + +.btn-save, +.btn-cancel { + padding: 6px var(--spacing-lg); + border: none; + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-save { + background: var(--color-primary); + color: var(--color-bg-white); +} + +.btn-save:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-save:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-cancel { + background: #e5e7eb; + color: var(--color-text-primary); +} + +.btn-cancel:hover:not(:disabled) { + background: #d1d5db; +} + +/* === NEW CARD FORM === */ +.new-card-form { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-xs); +} + +.new-card-input { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + outline: none; + background: var(--color-bg-white); + transition: border-color var(--transition-fast); + font-family: var(--font-family); +} + +.new-card-input:focus { + border-color: var(--color-primary); +} + +.btn-add-card { + padding: 6px var(--spacing-md); + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-bg-white); + font-size: var(--font-size-xs); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-add-card:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-add-card:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* === ADD COLUMN CONTAINER === */ +.add-column-container { + flex-shrink: 0; + width: 280px; + padding: var(--spacing-lg); + background: rgba(255, 255, 255, 0.5); + border-radius: var(--radius-md); + border: 2px dashed var(--color-border-dark); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + transition: all var(--transition-fast); +} + +.add-column-container:hover { + background: rgba(255, 255, 255, 0.8); + border-color: var(--color-primary); +} + +.new-column-input { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border-dark); + border-radius: var(--radius-sm); + font-size: var(--font-size-base); + outline: none; + background: var(--color-bg-white); + transition: border-color var(--transition-fast); + font-family: var(--font-family); +} + +.new-column-input:focus { + border-color: var(--color-primary); +} + +.btn-add-column { + padding: var(--spacing-sm) var(--spacing-lg); + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-bg-white); + font-size: var(--font-size-base); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-add-column:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.btn-add-column:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* === EMPTY STATES === */ +.empty-state { + padding: 40px var(--spacing-xl); + text-align: center; + color: var(--color-text-tertiary); + font-size: var(--font-size-base); +} + +.empty-state-center { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 40px; +} + +.empty-icon { + font-size: 4rem; + margin-bottom: var(--spacing-lg); + opacity: 0.3; +} + +.empty-state-center h3 { + margin: 0 0 var(--spacing-lg) 0; + color: var(--color-text-tertiary); + font-weight: 600; + font-size: 1.25rem; +} + +.btn-primary { + padding: var(--spacing-md) var(--spacing-2xl); + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: var(--color-bg-white); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background var(--transition-fast); +} + +.btn-primary:hover { + background: var(--color-primary-hover); +} + +/* === ERROR MESSAGE === */ +.error-message { + margin: var(--spacing-lg) 32px; + padding: var(--spacing-md) var(--spacing-lg); + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: var(--radius-sm); + color: #856404; + font-size: var(--font-size-sm); +} + +/* === LOADING === */ +.loading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + color: var(--color-text-tertiary); +} diff --git a/web/src/components/Boards.tsx b/web/src/components/Boards.tsx new file mode 100644 index 0000000..f4331c3 --- /dev/null +++ b/web/src/components/Boards.tsx @@ -0,0 +1,1208 @@ +import { useState, useEffect, useCallback } from 'react'; +import { loadAuthState } from '../lib/auth'; +import { encryptBlob, decryptBlob } from '../lib/crypto'; +import { upsertBlob, getBlob } from '../lib/api'; +import './Boards.css'; + +type BoardCard = { + id: string; + title: string; + content?: string; + createdAt: number; + updatedAt: number; + archived?: boolean; +}; + +type BoardColumn = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + archived?: boolean; + cards: BoardCard[]; +}; + +type Board = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + archived?: boolean; + columns: BoardColumn[]; +}; + +type BoardsData = { + boards: Board[]; +}; + +const BLOB_NAME = 'boards'; + +export default function Boards() { + const [boards, setBoards] = useState([]); + const [selectedBoard, setSelectedBoard] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + // Board management + const [isCreatingBoard, setIsCreatingBoard] = useState(false); + const [newBoardTitle, setNewBoardTitle] = useState(''); + const [isRenamingBoard, setIsRenamingBoard] = useState(false); + const [renameBoardTitle, setRenameBoardTitle] = useState(''); + + // Column management + const [newColumnTitle, setNewColumnTitle] = useState(''); + const [editingColumnId, setEditingColumnId] = useState(null); + const [editColumnTitle, setEditColumnTitle] = useState(''); + + // Card management + const [newCardTitles, setNewCardTitles] = useState<{ [columnId: string]: string }>({}); + const [editingCardId, setEditingCardId] = useState(null); + const [editCardTitle, setEditCardTitle] = useState(''); + const [editCardContent, setEditCardContent] = useState(''); + const [expandedCardId, setExpandedCardId] = useState(null); + + // View filters + const [showArchived, setShowArchived] = useState(false); + + // Drag and drop + const [draggedCard, setDraggedCard] = useState<{ card: BoardCard; fromColumnId: string } | null>(null); + const [dragOverColumnId, setDragOverColumnId] = useState(null); + + const loadBoards = useCallback(async () => { + setLoading(true); + setError(''); + + try { + const authState = loadAuthState(); + if (!authState) throw new Error('Not authenticated'); + + try { + const response = await getBlob(authState.token, BLOB_NAME); + const decrypted = await decryptBlob( + response.encryptedBlob, + authState.accountKey, + BLOB_NAME + ); + + const data: BoardsData = JSON.parse(decrypted); + const sorted = (data.boards || []).sort((a, b) => b.updatedAt - a.updatedAt); + setBoards(sorted); + } catch (err) { + const error = err as { status?: number }; + if (error.status === 404) { + setBoards([]); + } else { + throw err; + } + } + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to load boards'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadBoards(); + }, [loadBoards]); + + const saveBoards = async (updatedBoards: Board[]) => { + const authState = loadAuthState(); + if (!authState) throw new Error('Not authenticated'); + + const data: BoardsData = { boards: updatedBoards }; + const encrypted = await encryptBlob( + data, + authState.accountKey, + BLOB_NAME + ); + + await upsertBlob(authState.token, BLOB_NAME, { + encryptedBlob: encrypted, + }); + }; + + // Board operations + const handleCreateBoard = () => { + setIsCreatingBoard(true); + setNewBoardTitle(''); + }; + + const handleSaveNewBoard = async () => { + if (!newBoardTitle.trim()) { + setError('Board title is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const newBoard: Board = { + id: crypto.randomUUID(), + title: newBoardTitle, + columns: [], + createdAt: now, + updatedAt: now, + }; + + const updatedBoards = [newBoard, ...boards]; + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(newBoard); + setIsCreatingBoard(false); + setNewBoardTitle(''); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to create board'); + } finally { + setSaving(false); + } + }; + + const handleCancelNewBoard = () => { + setIsCreatingBoard(false); + setNewBoardTitle(''); + }; + + const handleSelectBoard = (board: Board) => { + setSelectedBoard(board); + setNewCardTitles({}); + setEditingCardId(null); + setExpandedCardId(null); + }; + + const handleStartRenameBoard = () => { + if (!selectedBoard) return; + setIsRenamingBoard(true); + setRenameBoardTitle(selectedBoard.title); + }; + + const handleSaveRenameBoard = async () => { + if (!selectedBoard) return; + if (!renameBoardTitle.trim()) { + setError('Board title is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedBoard = { + ...selectedBoard, + title: renameBoardTitle, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + setIsRenamingBoard(false); + setRenameBoardTitle(''); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to rename board'); + } finally { + setSaving(false); + } + }; + + const handleCancelRenameBoard = () => { + setIsRenamingBoard(false); + setRenameBoardTitle(''); + }; + + const handleToggleArchiveBoard = async () => { + if (!selectedBoard) return; + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedBoard = { + ...selectedBoard, + archived: !selectedBoard.archived, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to archive board'); + } finally { + setSaving(false); + } + }; + + const handleDeleteBoard = async () => { + if (!selectedBoard) return; + if (!confirm(`Delete board "${selectedBoard.title}"? This will delete all columns and cards.`)) return; + + setSaving(true); + setError(''); + + try { + const updatedBoards = boards.filter((b) => b.id !== selectedBoard.id); + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(null); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to delete board'); + } finally { + setSaving(false); + } + }; + + // Column operations + const handleAddColumn = async () => { + if (!selectedBoard) return; + if (!newColumnTitle.trim()) { + setError('Column title is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const newColumn: BoardColumn = { + id: crypto.randomUUID(), + title: newColumnTitle, + cards: [], + createdAt: now, + updatedAt: now, + }; + + const updatedBoard = { + ...selectedBoard, + columns: [...selectedBoard.columns, newColumn], + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + setNewColumnTitle(''); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to add column'); + } finally { + setSaving(false); + } + }; + + const handleStartEditColumn = (columnId: string, currentTitle: string) => { + setEditingColumnId(columnId); + setEditColumnTitle(currentTitle); + }; + + const handleSaveEditColumn = async (columnId: string) => { + if (!selectedBoard) return; + if (!editColumnTitle.trim()) { + setError('Column title is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedColumns = selectedBoard.columns.map((col) => + col.id === columnId + ? { ...col, title: editColumnTitle, updatedAt: now } + : col + ); + + const updatedBoard = { + ...selectedBoard, + columns: updatedColumns, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + setEditingColumnId(null); + setEditColumnTitle(''); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to update column'); + } finally { + setSaving(false); + } + }; + + const handleCancelEditColumn = () => { + setEditingColumnId(null); + setEditColumnTitle(''); + }; + + const handleDeleteColumn = async (columnId: string) => { + if (!selectedBoard) return; + const column = selectedBoard.columns.find((c) => c.id === columnId); + if (!column) return; + if (column.cards.length > 0) { + if (!confirm(`Delete column "${column.title}" with ${column.cards.length} cards?`)) return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedColumns = selectedBoard.columns.filter((col) => col.id !== columnId); + + const updatedBoard = { + ...selectedBoard, + columns: updatedColumns, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to delete column'); + } finally { + setSaving(false); + } + }; + + const handleToggleArchiveColumn = async (columnId: string) => { + if (!selectedBoard) return; + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedColumns = selectedBoard.columns.map((col) => + col.id === columnId + ? { ...col, archived: !col.archived, updatedAt: now } + : col + ); + + const updatedBoard = { + ...selectedBoard, + columns: updatedColumns, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to archive column'); + } finally { + setSaving(false); + } + }; + + // Card operations + const handleAddCard = async (columnId: string) => { + if (!selectedBoard) return; + const cardTitle = newCardTitles[columnId] || ''; + if (!cardTitle.trim()) { + setError('Card title is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const newCard: BoardCard = { + id: crypto.randomUUID(), + title: cardTitle, + createdAt: now, + updatedAt: now, + }; + + const updatedColumns = selectedBoard.columns.map((col) => + col.id === columnId + ? { ...col, cards: [...col.cards, newCard], updatedAt: now } + : col + ); + + const updatedBoard = { + ...selectedBoard, + columns: updatedColumns, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + setNewCardTitles({ ...newCardTitles, [columnId]: '' }); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to add card'); + } finally { + setSaving(false); + } + }; + + const handleStartEditCard = (card: BoardCard) => { + setEditingCardId(card.id); + setEditCardTitle(card.title); + setEditCardContent(card.content || ''); + setExpandedCardId(null); + }; + + const handleSaveEditCard = async (columnId: string, cardId: string) => { + if (!selectedBoard) return; + if (!editCardTitle.trim()) { + setError('Card title is required'); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedColumns = selectedBoard.columns.map((col) => + col.id === columnId + ? { + ...col, + cards: col.cards.map((card) => + card.id === cardId + ? { ...card, title: editCardTitle, content: editCardContent, updatedAt: now } + : card + ), + updatedAt: now, + } + : col + ); + + const updatedBoard = { + ...selectedBoard, + columns: updatedColumns, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + setEditingCardId(null); + setEditCardTitle(''); + setEditCardContent(''); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to update card'); + } finally { + setSaving(false); + } + }; + + const handleCancelEditCard = () => { + setEditingCardId(null); + setEditCardTitle(''); + setEditCardContent(''); + }; + + const handleDeleteCard = async (columnId: string, cardId: string) => { + if (!selectedBoard) return; + if (!confirm('Delete this card?')) return; + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedColumns = selectedBoard.columns.map((col) => + col.id === columnId + ? { + ...col, + cards: col.cards.filter((card) => card.id !== cardId), + updatedAt: now, + } + : col + ); + + const updatedBoard = { + ...selectedBoard, + columns: updatedColumns, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + if (expandedCardId === cardId) { + setExpandedCardId(null); + } + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to delete card'); + } finally { + setSaving(false); + } + }; + + const handleToggleArchiveCard = async (columnId: string, cardId: string) => { + if (!selectedBoard) return; + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedColumns = selectedBoard.columns.map((col) => + col.id === columnId + ? { + ...col, + cards: col.cards.map((card) => + card.id === cardId + ? { ...card, archived: !card.archived, updatedAt: now } + : card + ), + updatedAt: now, + } + : col + ); + + const updatedBoard = { + ...selectedBoard, + columns: updatedColumns, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to archive card'); + } finally { + setSaving(false); + } + }; + + const handleToggleExpandCard = (cardId: string) => { + setExpandedCardId(expandedCardId === cardId ? null : cardId); + }; + + // Drag and drop handlers + const handleDragStart = (card: BoardCard, columnId: string) => { + setDraggedCard({ card, fromColumnId: columnId }); + }; + + const handleDragEnd = () => { + setDraggedCard(null); + setDragOverColumnId(null); + }; + + const handleDragOver = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumnId(columnId); + }; + + const handleDragLeave = () => { + setDragOverColumnId(null); + }; + + const handleDrop = async (e: React.DragEvent, toColumnId: string) => { + e.preventDefault(); + if (!selectedBoard || !draggedCard) return; + + const { card, fromColumnId } = draggedCard; + + // If dropping in same column, do nothing + if (fromColumnId === toColumnId) { + setDraggedCard(null); + setDragOverColumnId(null); + return; + } + + setSaving(true); + setError(''); + + try { + const now = Date.now(); + const updatedColumns = selectedBoard.columns.map((col) => { + if (col.id === fromColumnId) { + // Remove card from source column + return { + ...col, + cards: col.cards.filter((c) => c.id !== card.id), + updatedAt: now, + }; + } else if (col.id === toColumnId) { + // Add card to target column + return { + ...col, + cards: [...col.cards, { ...card, updatedAt: now }], + updatedAt: now, + }; + } + return col; + }); + + const updatedBoard = { + ...selectedBoard, + columns: updatedColumns, + updatedAt: now, + }; + + const updatedBoards = boards.map((b) => + b.id === selectedBoard.id ? updatedBoard : b + ).sort((a, b) => b.updatedAt - a.updatedAt); + + await saveBoards(updatedBoards); + setBoards(updatedBoards); + setSelectedBoard(updatedBoard); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to move card'); + } finally { + setSaving(false); + setDraggedCard(null); + setDragOverColumnId(null); + } + }; + + // Export/Import + const handleExport = () => { + try { + const exportData: BoardsData = { boards }; + const dataStr = JSON.stringify(exportData, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + + const link = document.createElement('a'); + link.href = url; + link.download = `boards-export-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to export boards'); + } + }; + + const handleImport = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + + input.onchange = async (e: Event) => { + const target = e.target as HTMLInputElement; + const file = target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const importData: BoardsData = JSON.parse(text); + + if (!importData.boards || !Array.isArray(importData.boards)) { + throw new Error('Invalid boards data format'); + } + + if (!confirm(`Import ${importData.boards.length} boards? This will replace all existing data.`)) { + return; + } + + setSaving(true); + setError(''); + + const sorted = importData.boards.sort((a, b) => b.updatedAt - a.updatedAt); + await saveBoards(sorted); + setBoards(sorted); + setSelectedBoard(null); + + alert(`Successfully imported ${sorted.length} boards!`); + } catch (err) { + const error = err as Error; + setError(error.message || 'Failed to import boards'); + } finally { + setSaving(false); + } + }; + + input.click(); + }; + + // Helper functions + const getVisibleBoards = () => { + if (showArchived) { + return boards; + } + return boards.filter(b => !b.archived); + }; + + const getVisibleColumns = () => { + if (!selectedBoard) return []; + let columns = showArchived ? selectedBoard.columns : selectedBoard.columns.filter(c => !c.archived); + + // Filter cards within each column based on showArchived + columns = columns.map(col => ({ + ...col, + cards: showArchived ? col.cards : col.cards.filter(card => !card.archived) + })); + + return columns; + }; + + const getBoardStats = (board: Board) => { + const totalColumns = board.columns.length; + const totalCards = board.columns.reduce((sum, col) => sum + col.cards.length, 0); + return { totalColumns, totalCards }; + }; + + if (loading) { + return ( +
+
Loading boards...
+
+ ); + } + + const visibleBoards = getVisibleBoards(); + const visibleColumns = getVisibleColumns(); + + return ( +
+ {/* Left Sidebar - Boards */} +
+
+

Boards

+ +
+ +
+ + +
+ +
+ +
+ + {isCreatingBoard && ( +
+ setNewBoardTitle(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter' && newBoardTitle.trim()) { + handleSaveNewBoard(); + } + }} + autoFocus + /> +
+ + +
+
+ )} + +
+ {visibleBoards.length === 0 ? ( +
+ {showArchived ? 'No boards yet.' : 'No active boards. Create one!'} +
+ ) : ( + visibleBoards.map((board) => { + const stats = getBoardStats(board); + return ( +
handleSelectBoard(board)} + > +
+ {board.archived && 📦} + {board.title} +
+
+ {stats.totalColumns} columns · {stats.totalCards} cards +
+
+ ); + }) + )} +
+
+ + {/* Main Board View */} +
+ {selectedBoard ? ( + <> +
+ {isRenamingBoard ? ( +
+ setRenameBoardTitle(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter' && renameBoardTitle.trim()) { + handleSaveRenameBoard(); + } + }} + autoFocus + /> +
+ + +
+
+ ) : ( + <> +
+

{selectedBoard.title}

+ {selectedBoard.archived && Archived} +
+
+ + + +
+ + )} +
+ + {error &&
{error}
} + +
+
+ {visibleColumns.map((column) => ( +
handleDragOver(e, column.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, column.id)} + > +
+ {editingColumnId === column.id ? ( +
+ setEditColumnTitle(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter' && editColumnTitle.trim()) { + handleSaveEditColumn(column.id); + } + }} + autoFocus + /> +
+ + +
+
+ ) : ( + <> +

+ {column.archived && 📦} + {column.title} + {column.cards.length} +

+
+ + + +
+ + )} +
+ +
+ {column.cards.map((card) => ( +
handleDragStart(card, column.id)} + onDragEnd={handleDragEnd} + > + {editingCardId === card.id ? ( +
+ setEditCardTitle(e.target.value)} + /> +