Compare commits

...
Author SHA1 Message Date
Linus Rath a834528bc1 chore: update version to 1.3.0 and enhance changelog with new features and fixes 2026-03-16 22:58:13 +01:00
Linus Rath 7cf79d8775 fix: resolve test failures across translations, contact form, and vitest config
- Add 18 missing translation keys to all non-English locales (de, es, fr, it, ja, nl, pt) with English fallback values
- Fix contact form submit test targeting prefix input instead of given_name
- Exclude .next/ build output from vitest to prevent Playwright spec conflicts
2026-03-16 22:51:49 +01:00
Linus Rath dc3e5b7706 fix: adjust text wrapping for email subject in EmailViewer and ThreadConversationView components 2026-03-16 22:22:05 +01:00
Linus Rath d86d109725 feat: enhance EventModal with alert and recurrence labels, add view/edit mode toggle 2026-03-16 22:17:59 +01:00
Linus Rath 0f5d030d5f feat: calendar invitations RSVP, trust assessment, file preview 2026-03-16 22:00:11 +01:00
Linus Rath cde1d61d02 fix: correct all-day multi-day event rendering 2026-03-16 17:57:56 +01:00
Linus Rath 4b262a2746 feat: update LICENSE to include fork notice and improve formatting 2026-03-16 17:42:55 +01:00
Linus Rath 119bfed1eb feat: update licensing information to AGPL-3.0-only and add NOTICE file for fork lineage 2026-03-16 16:46:45 +01:00
Linus Rath 3cd123778e feat: add keyboard shortcuts button and show/hide toolbar labels option
- Implemented a keyboard shortcuts button in the navigation rail.
- Added a setting to toggle the visibility of toolbar labels in appearance settings.
- Updated the email context menu to streamline actions for archiving, deleting, and marking emails as read/unread.
- Enhanced the email viewer with improved spam handling and read state toggling.
- Updated localization files to include new strings for keyboard shortcuts and toolbar labels.
2026-03-16 16:19:51 +01:00
Linus Rath f2f3e1d85c feat: update README with enhanced descriptions and images for various features 2026-03-16 15:43:47 +01:00
Linus Rath f7a38a22ee Refactor code structure for improved readability and maintainability 2026-03-16 15:40:46 +01:00
Linus Rath e759056d5f Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-16 15:18:29 +01:00
Linus Rath 1b35c9e3d6 feat: enhance contacts management with sidebar and selection features 2026-03-16 15:15:58 +01:00
Linus Rath 89488e6fc9 fix: add missing newline in README for better formatting 2026-03-16 15:10:36 +01:00
Linus Rath 7af16f2de0 feat: enhance README with new features and improvements in functionality 2026-03-16 15:07:11 +01:00
Linus Rath ed4be96706 fix: update sidebar border styling for consistency across pages 2026-03-16 11:02:34 +01:00
Linus Rath 8044c60905 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-16 09:54:03 +01:00
Linus Rath 8d0c525e68 fix: update avatar background color logic based on image source 2026-03-16 09:50:50 +01:00
Linus Rath 800499f54a feat: implement sidebar resizing functionality across calendar, contacts, and settings pages 2026-03-16 02:32:22 +01:00
Linus Rath e02477323f feat: add contacts settings with import/export functionality and update UI components 2026-03-16 02:18:22 +01:00
Linus Rath 37b0bacccb feat: enhance settings page with tab icons and grouping
feat(files-settings): improve file settings preview and layout
feat(locales): update translations for tab groups and add preview labels
2026-03-16 02:10:29 +01:00
Linus Rath 716db8d003 feat: add JMAP FileNode file storage backend and file settings
- Implement JMAP FileNode client methods (get, query, list, create,
  update, destroy, copy) with Stalwart-compatible flat name encoding
- Add FileNode/FileNodeFilter types to JMAP type definitions
- Create file-store with Zustand for file management state (navigate,
  upload, delete, rename, move, cut/copy/paste, undo, favorites)
- Add folder tree sidebar component for sidebar navigation layout
- Add files settings dialog and settings page component with options
  for view mode, sort, icons, thumbnails, hidden files, folder layout
- Update files page and file browser to support JMAP FileNode backend
  alongside WebDAV, with folder layout switching and settings integration
- Add settings tab for files configuration in the settings page
- Add translation keys for file settings, calendar subscriptions,
  identity deletion, contact deletion, email navigation, and
  reconnection messages across all 8 locales
- Change WebDAV file storage to File storage in availability messages
- Enhance translations test to verify source-referenced keys exist in
  the en locale
- Fix duplicate JSX attribute in folder-tree-sidebar
2026-03-16 01:05:59 +01:00
Linus Rath 73d6e581b7 feat(email-viewer): add security tooltips, contact actions, and scroll fix 2026-03-15 18:52:57 +01:00
Linus Rath f4d34477a6 feat(email-viewer): improve message details and contact sidebar
- Show sender name and email in message details From field
- Add "Add to contacts" button in contact sidebar for unknown senders
- Add info tooltips explaining SPF, DKIM, DMARC, and Spam Score
- Use portal-based tooltips to prevent overflow clipping
- Move desktop sender info into scrollable area to fix scroll when details expanded
2026-03-15 18:52:01 +01:00
Linus Rath 26b38982ac fix: adjust email content area layout for better responsiveness 2026-03-15 18:15:33 +01:00
Linus Rath 9bffb72338 feat: iframe-based email rendering with smart dark mode support
- Render HTML emails in sandboxed iframe (srcdoc) for true-to-life
  display with complete CSS isolation from app styles
- Detect emails with native dark mode (prefers-color-scheme) and
  let them handle their own theming
- Apply CSS filter inversion for dark mode on emails without native
  support, with re-inversion for images/media to preserve appearance
- Add per-email light/dark toggle button (Sun/Moon icon) next to
  email size, resets on email change (not persisted)
- Fix HTML reply/forward to include original email HTML content
- Send replies as multipart/alternative (text + HTML)
- Add drag-and-drop file attachments with overlay indicator
- Auto-resize composer textarea to avoid double scrolling
- Pin attachments section and bottom toolbar outside scroll area
- Collapsible attachment list (show 3, toggle for more)
2026-03-15 17:42:41 +01:00
Linus Rath 0965fbc7c1 feat: add iCal/webcal subscription support to calendar view
- Add subscribe option to toolbar import dropdown menu
- Add right-click context menu on subscribed calendars (refresh/unsubscribe)
- Show refresh spinner on subscription calendars during sync
- Support webcal:// protocol URLs (auto-converts to https://)
- Update i18n strings with webcal:// references
2026-03-15 16:29:14 +01:00
Linus Rath d6cf195bfc Fix nested button hydration error in sidebar mailbox tree 2026-03-15 16:13:06 +01:00
Linus Rath 0080d27b3e fix: sync identity stores and append signatures to outgoing emails (#15)
- Add syncIdentities() to auth store to propagate identity changes from
  identity store, fixing stale data that caused save failures and duplicates
- Call syncIdentities() after every create, update, and delete in the
  identity manager modal
- Switch email composer to read identities from identity store for
  consistency with the rest of the app
- Append identity text signature (with RFC 3676 separator) to email body
  when sending from the composer and quick reply paths
- Add tests for syncIdentities and signature appending logic
2026-03-15 16:07:45 +01:00
Linus Rath 5643170220 fix: handle non-string anniversary dates in contact detail
JMAP servers can return PartialDate objects for anniversary dates per RFC 9553, causing a TypeError on .startsWith(). Coerce date input and guard URI property accesses.
2026-03-15 15:56:47 +01:00
Linus Rath e87e35cc48 feat: add recursive depth calculation for mailbox tree structure 2026-03-15 15:52:23 +01:00
Linus Rath b7fa25c314 feat: implement empty folder functionality for junk and trash mailboxes with confirmation dialog 2026-03-15 15:47:58 +01:00
Linus Rath c24fabe977 feat: add unread filter functionality in mailbox sidebar 2026-03-15 15:32:34 +01:00
Linus Rath 1f1db8fac8 feat: add WebDAV file browser with auth improvements
Add a new Files section powered by WebDAV for browsing, uploading,
downloading, renaming, and deleting files and folders.

New features:
- WebDAV file browser with grid/list views and breadcrumb navigation
- File upload (drag-and-drop and button), folder creation, rename, delete
- File preview modals for images and other file types
- WebDAV proxy API route to handle authentication
- Navigation rail entry for Files (auto-hidden when WebDAV is unsupported)

Auth improvements:
- Fix premature redirects on calendar, contacts, and settings pages by
  adding explicit auth check on mount before redirecting to login
- Persist active settings tab in localStorage

Other:
- Expose getAuthHeader() and getServerUrl() on JMAPClient
- Add WebDAV store with connection testing and capability detection
- Add i18n translations for file browser in all 8 locales (de, en, es,
  fr, it, ja, nl, pt)
2026-03-15 05:24:27 +01:00
Linus Rath cb74e3bf73 feat: add mobile bottom action bar with reply and email navigation
- Move Reply, Reply All, Forward from toolbar to fixed bottom bar on mobile
- Add Next/Previous email navigation buttons to the bottom bar
- Style bottom bar to match existing NavigationRail horizontal pattern
- Add onNavigateNext/onNavigatePrev props to EmailViewer
- Wire up email list index-based navigation in page.tsx
- Add padding to email content area to prevent bottom bar overlap
2026-03-15 02:23:09 +01:00
Linus Rath 05f02402e3 feat: add move-to mailbox functionality in email viewer 2026-03-15 00:53:19 +01:00
Linus Rath 5accf0a86c feat: enhance contact group management with uid normalization and improved member handling 2026-03-15 00:40:32 +01:00
Linus Rath 8c5e3ee240 feat: enhance configuration fetching and implement retry logic for API requests 2026-03-15 00:21:59 +01:00
Linus Rath caed067cda feat: implement calendar management features including create, update, and delete functionalities 2026-03-15 00:14:58 +01:00
Linus Rath f149501dff feat: auto-fetch full email content when an email is auto-selected 2026-03-14 21:47:50 +01:00
Linus Rath a9a8710909 feat: mobile long-press context menu, settings logout button
Mobile:
- Add useLongPress hook (500ms, movement cancellation, press feedback)
- Disable drag-and-drop on mobile in useEmailDrag
- Wire long-press context menu to all email list item components
- Add select-none and visual press feedback (scale + ring)

Settings:
- Add logout button to settings page sidebar
2026-03-14 19:16:49 +01:00
Linus Rath d1478fa46b feat: expand supported locales in request configuration 2026-03-14 19:03:37 +01:00
Linus Rath d5404de224 feat: add extra-compact density option and fix font size scaling
- Add 'extra-compact' density level that hides avatars and preview
  text, showing only sender and subject for maximum information density
- Add visual density preview in appearance settings showing a 3-row
  email list mockup that reflects each density option
- Fix global font size setting only changing line height by moving
  font-size from body to :root so rem-based Tailwind classes scale
- Apply extra-compact behavior to email list items, thread list items,
  thread email items, and thread conversation view (EmailCard)
- Update email list virtualizer size estimates for extra-compact
- Add extra-compact translations for all 8 locales
2026-03-14 18:59:00 +01:00
Linus Rath 838de8e5da fix: make density setting functional across entire UI
Rename "List Density" to "Density" and apply spacing changes globally
instead of only to email list item heights.

- Replace hardcoded padding/gap values with CSS custom properties
  (--density-item-py, --density-item-gap, --density-header-py,
  --density-card-p, --density-sidebar-py) set via JS on :root
- Apply density-responsive spacing to email list items, thread views,
  email viewer, sidebar, navigation rail, contacts, and calendar
- Use inline styles instead of Tailwind arbitrary value classes to
  avoid Turbopack compilation hangs
- Rename listDensity → density in store, types, and components
- Add persist migration (v1 → v2) and onRehydrateStorage callback
- Update all 8 locale files with broadened labels/descriptions
2026-03-14 18:35:43 +01:00
Linus Rath 86673a7b45 chore: update version to 1.2.4 2026-03-14 17:17:22 +01:00
Linus Rath f7bbab5e4a feat: implement tag counts fetching and display in sidebar and email components 2026-03-14 17:12:38 +01:00
Linus Rath f995558bf5 feat: update email list messages for improved clarity and localization support 2026-03-14 16:59:36 +01:00
Linus Rath f1f31df0af feat: implement wildcard query functionality for enhanced search capabilities 2026-03-14 16:50:43 +01:00
Linus Rath c024d89477 feat: enhance email search results display with pluralization and localization support 2026-03-14 16:37:24 +01:00
Linus Rath 1936a37f28 feat: add destructive color variables and enhance unsubscribe banner functionality 2026-03-14 16:29:26 +01:00
Linus Rath 98cb3e158b feat: implement dropdown menus for actions in EmailViewer component 2026-03-14 16:14:13 +01:00
Linus Rath 85b5b3c4f1 feat: refactor authentication handling to use centralized Stalwart credentials management 2026-03-14 15:55:59 +01:00
Linus Rath 6fc27804d6 feat: integrate dev mode configuration for profile picture retrieval in Avatar component 2026-03-14 15:46:55 +01:00
Linus Rath 4a90cf4fa0 feat: enhance email search functionality to support OR conditions across multiple fields 2026-03-14 15:43:29 +01:00
Linus Rath 528515611e fix: add local private data directory to .gitignore 2026-03-14 15:32:12 +01:00
Linus Rath 6c4acdc655 fix: prevent browser auth dialog when viewing emails with inline images
Inline CID images were replaced with raw JMAP download URLs that require authentication. When the browser loaded these as <img src>, the server responded with WWW-Authenticate: Basic, triggering a native login popup.

- Add fetchBlobAsObjectUrl() to JMAPClient that fetches blobs via authenticated request and returns blob: object URLs
- Update email-viewer and thread-conversation-view to fetch CID images asynchronously with auth, using blob: URLs instead of raw server URLs
- Add ALLOWED_URI_REGEXP to DOMPurify config so blob: URLs are not stripped during sanitization
- Add tests for fetchBlobAsObjectUrl and CID/blob URL sanitization
2026-03-14 13:39:56 +01:00
Linus Rath 202a75db0c Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-14 01:22:32 +01:00
Linus Rath d7b99ac95c docs: add acknowledgments section to README.md 2026-03-14 01:22:07 +01:00
Linus RathandGitHub 596ebecfbb Update logo file names in README.md 2026-03-14 01:04:05 +01:00
Linus Rath 0ea79f29db fix: optimize theme store usage with shallow comparison in LoginPage 2026-03-13 23:11:12 +01:00
Linus Rath 4396bc9943 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-13 19:48:07 +01:00
Linus Rath 2fe1b01d4d feat: add configurable logo to login page with light/dark mode support
- Display Bulwark logo on the login page, with theme-aware switching
  between light and dark variants based on the resolved theme
- Add LOGIN_LOGO_LIGHT_URL and LOGIN_LOGO_DARK_URL env variables to
  allow custom logo overrides (defaults to bundled Bulwark branding)
- Expose logo config through /api/config endpoint and useConfig hook
- Add logo URL prompts to the setup.sh installer branding step
- Rename branding assets to use underscores instead of spaces
2026-03-13 19:45:09 +01:00
Linus Rath d49dcb52e6 feat: add .env.dev.example for development configuration 2026-03-13 19:20:28 +01:00
Linus Rath 81c0a8a971 chore: include .env.dev.example in .dockerignore 2026-03-13 19:18:55 +01:00
Linus Rath 56f4195ab5 chore: update dependencies in changelog for version 1.2.3 2026-03-13 15:05:13 +01:00
Linus Rath 099434a125 chore: release v1.2.3
Bump version to 1.2.3 across package.json, VERSION, README badge,
and login page. Add changelog entry for features and fixes since v1.1.2.
2026-03-13 14:48:32 +01:00
Linus Rath 0cd928d1fe URL-encode spaces in logo srcset paths for GitHub compatibility 2026-03-13 14:36:02 +01:00
Linus Rath 04e8b023de Fix logo dark/light mode: white text for dark bg, dark text for light bg 2026-03-13 14:34:22 +01:00
Linus Rath ecfb197c82 Swap dark/light mode logo sources to match actual file contents 2026-03-13 14:33:00 +01:00
Linus Rath 9438cf75ab Update logos to use lettering variants with light/dark mode support 2026-03-13 14:30:39 +01:00
Linus Rath 091c10d828 Add branding assets, update logos in README/CONTRIBUTING/ROADMAP, replace favicon with SVG icon 2026-03-13 14:27:35 +01:00
Linus Rath 02808fa4c3 docs: update contributing guidelines and enhance README formatting 2026-03-13 14:12:20 +01:00
Linus Rath 82a790de0f docs: rework README with logo, improved layout, and updated features 2026-03-13 13:17:43 +01:00
Linus Rath e85d0b946e chore: rename JMAP Webmail to Bulwark Webmail and update all URLs 2026-03-13 13:14:08 +01:00
Linus Rath 02344d169c feat: expand addon system documentation with rebuild requirements and runtime loading strategy 2026-03-13 03:16:07 +01:00
Linus Rath 1267efa307 docs: add RFC specifications (txt + pdf) organized by category (core, mail, contacts, calendar, sieve, quotas, auth) 2026-03-13 02:51:07 +01:00
Linus Rath 5a679275ca feat: enhance JMAP server verification with multiple endpoint checks and improved response handling 2026-03-13 01:19:16 +01:00
Linus Rath 25f0088957 feat: add JMAP server URL validation and improve user prompts in setup script 2026-03-13 01:14:56 +01:00
Linus Rath 18485951c7 refactor: rework setup.sh for better UX and terminal compatibility
- Replace all Unicode symbols (─, █, ░, ●, ✓, ✗, ➜, braille spinners)
  with ASCII-safe equivalents that render correctly when piped via
  curl | bash
- Add contextual TIP/NOTE hints to every input field explaining what
  each setting does and showing examples
- Redesign progress bar to show labeled step names
  (Server > Auth > Security > Logging > Branding > Deploy > Confirm)
  with color-coded completion state
- Replace BG_BLUE block header with ASCII box() function
- Add section headers with subtitles to each step
- Add deployment method recommendations and requirement notes
- Add "Next steps" checklist to completion screen
- Show existing .env.local detection notice on welcome screen
- Use dotted-line separators in summary for cleaner alignment
- Fix shell injection vulnerability: replace eval with printf -v
  in prompt_value and prompt_yesno helpers
2026-03-13 01:08:47 +01:00
Linus Rath 5e2329b55f fix: handle non-interactive terminal scenarios in setup script 2026-03-13 00:58:06 +01:00
Linus Rath 46a80f3773 fix: reopen stdin from terminal when script is piped 2026-03-13 00:48:19 +01:00
Linus Rath 21e6fd9078 feat: reopen stdin from terminal when script is piped 2026-03-13 00:45:01 +01:00
Linus Rath 2a48740ebd fix: ensure SCRIPT_DIR assignment is not affected by unset variables 2026-03-13 00:43:53 +01:00
Linus Rath 8509550812 refactor: simplify SCRIPT_DIR assignment in setup script 2026-03-13 00:42:42 +01:00
Linus Rath 60a6ede063 fix: ensure SCRIPT_DIR is set correctly in setup script 2026-03-13 00:40:06 +01:00
Linus Rath eb6e73191f feat: add dry-run option to setup script for safer configuration testing 2026-03-13 00:36:51 +01:00
Linus Rath af64c96b9f feat: add interactive setup script for JMAP Webmail configuration 2026-03-13 00:36:22 +01:00
Linus Rath b090bf6d52 feat: add support for OAuth-only login mode and update configuration handling 2026-03-12 22:05:29 +01:00
Linus Rath ae29e64197 feat: implement hover functionality for calendar events and enhance email deletion confirmation 2026-03-12 19:53:08 +01:00
Linus Rath 260ea7088d Add localization updates for Dutch and Portuguese languages
- Enhanced error messages and connection prompts in Dutch and Portuguese.
- Added new UI text for password visibility, TOTP toggle, and remember me options.
- Improved sidebar and email composer hints for better user guidance.
- Introduced folder management features with relevant translations.
- Updated contact management fields and labels for clarity and completeness.
- General cleanup and reorganization of existing translations for consistency.
2026-03-12 16:24:03 +01:00
Linus Rath db8e827f68 feat: add tests for translations completeness across locales 2026-03-12 16:14:35 +01:00
Linus Rath 6f0d47cbd9 feat: improve email selection logic after deletion and archiving 2026-03-12 16:12:16 +01:00
Linus Rath 2a73adf588 feat: update layout height to use dynamic viewport height for better responsiveness 2026-03-12 16:05:40 +01:00
Linus Rath 7fedcb8e58 feat: enhance contact management and vCard support
- Added support for parsing and generating additional vCard properties including GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, and SOURCE.
- Extended ContactCard interface to include new fields such as gender, media, anniversaries, online services, and personal info.
- Implemented logic to handle multi-part TLDs for domain extraction in avatars.
- Improved avatar component to prioritize contact photos and handle inline images in emails.
- Updated localization files to include new fields and labels for contact details.
- Refactored contact store to expose a method for retrieving contact photos.
- Enhanced unit tests to cover new vCard properties and ensure correct parsing and generation.
2026-03-12 15:54:09 +01:00
Linus Rath 4737f2928b Remove Docker Hub, publish only to GHCR 2026-03-12 13:25:14 +01:00
Linus Rath d1d2a1dc53 Merge branch experimental into main 2026-03-12 13:16:13 +01:00
Linus Rath 3efb292c3b feat: implement negative caching for favicon requests to improve performance 2026-03-12 03:14:07 +01:00
Linus Rath bb40326809 feat: implement tagging functionality for emails with drag-and-drop support 2026-03-12 03:10:40 +01:00
Linus Rath d8b0f013e8 style: update background colors for selected and unread email items 2026-03-12 02:43:38 +01:00
Linus Rath f751274e41 fix: fix print functionality for email content 2026-03-12 02:37:23 +01:00
Linus Rath 2249741c61 feat: add login page customization options and corresponding translations 2026-03-12 02:31:56 +01:00
Linus Rath ab72fc06ff feat: add Stalwart account security management
- Add Stalwart API client library (lib/stalwart/client.ts)
- Add server-side proxy routes for auth, crypto, password, principal, probe
- Add account security Zustand store with full state management
- Add Security settings tab with password change, display name, TOTP 2FA,
  app passwords, and encryption-at-rest controls
- Add stalwartFeaturesEnabled config flag (opt-out via STALWART_FEATURES=false)
- Add i18n translations for all 8 locales (en, de, es, fr, it, ja, nl, pt)
- Add tests for Stalwart client (24 tests) and security store (29 tests)
2026-03-12 02:20:56 +01:00
Linus Rath 68d4a9a641 feat: add email selection functionality with toggle and update translations 2026-03-12 00:32:36 +01:00
Linus Rath 5598c436f2 feat: add connection loss handling and update auth store 2026-03-12 00:08:52 +01:00
Linus Rath 256ff7698d feat: enhance email selection with checkbox functionality and improve session handling 2026-03-11 23:57:06 +01:00
Linus Rath 7848c90625 feat: add error handling for sessionStorage in redirect logic across multiple pages 2026-03-11 23:35:27 +01:00
Linus Rath f996ab77b8 feat: implement redirect after login functionality across multiple pages 2026-03-11 23:35:00 +01:00
Linus Rath a88b4e4407 feat: enhance folder settings with subfolder creation and navigation improvements 2026-03-11 23:19:01 +01:00
Linus Rath cc8195209b feat: add toolbar position setting and update email viewer layout
- Introduced a new setting for toolbar position in appearance settings, allowing users to choose between 'top' and 'below-subject'.
- Updated the EmailViewer component to conditionally render the toolbar based on the selected position.
- Enhanced the UI for the toolbar with action buttons for replying, archiving, and deleting emails.
- Added translations for the new toolbar position setting in multiple languages.
2026-03-11 22:42:21 +01:00
Linus Rath 288205fbac feat: add recipient popover component for enhanced contact interaction 2026-03-11 21:23:57 +01:00
Linus Rath 15dbb3d349 feat: update email composer to handle trailing commas and improve recipient input handling 2026-03-11 21:07:00 +01:00
Linus Rath 5f539161d5 feat: enhance toast component with enter/exit animations and progress bar 2026-03-11 20:40:24 +01:00
Linus Rath d3a5b4189e feat: enhance event modal for desktop and mobile views 2026-03-11 20:35:25 +01:00
Linus Rath e5c1cd45c5 feat: europeanize mock JMAP data and refine email list layout
Replace all mock data in the dev JMAP server with European-themed
content: 42 emails (was 13), 20 contacts across 2 address books
(was 3), 5 calendars and 35 events with recurrence, overlaps,
and all-day entries.

Move keyword tags in email/thread list items from the subject line
to the header row alongside the date for a cleaner layout.

Update test assertions to match the new mock data.
2026-03-11 20:21:39 +01:00
Linus Rath 454d51e283 feat: refactor email tagging system to use labels instead of colors
- Updated EmailContextMenu to replace color tag functionality with label tags.
- Modified EmailListItem to display label badges for emails based on keywords.
- Enhanced EmailViewer to include a tag picker for emails.
- Adjusted ThreadListItem to show label badges for email subjects.
- Added tests for email list item and keyword settings to ensure proper functionality of the new tagging system.
- Updated localization files to reflect changes from color tags to labels in multiple languages.
2026-03-11 18:56:43 +01:00
Linus Rath ffeb534740 feat: implement email keyword management with color tagging
- Introduced a new settings section for managing email keywords (labels/tags) with associated colors.
- Updated email context menu, viewer, and list item components to support the new keyword system.
- Refactored color tag handling to accommodate both new $label: and legacy $color: prefixes.
- Added utility functions for retrieving email color tags based on keywords.
- Enhanced localization files to include translations for the new keyword management features.
- Updated the settings store to manage keyword definitions, including add, update, delete, and reorder functionalities.
- Added tests for the new keyword handling logic.
2026-03-11 18:40:09 +01:00
Linus Rath 61459c4923 feat: update bottom navigation to support tablet view in mobile layout 2026-03-11 18:13:19 +01:00
Linus Rath d23e9a642c feat: enhance mobile responsiveness across calendar and contact components
- Added `isMobile` prop to `CalendarWeekView`, `EventDetailPopover`, `ContactDetail`, `ContactGroupDetail`, and `EmailComposer` components for improved mobile layout.
- Adjusted layout and styles in `CalendarWeekView` to display a 3-day view on mobile.
- Updated `EventDetailPopover` to adapt its size and layout for mobile devices.
- Modified `ContactDetail` and `ContactGroupDetail` to adjust padding and font sizes based on mobile view.
- Enhanced `EmailComposer` with mobile-friendly header and field layouts, including auto-saving draft functionality.
- Added confirmation dialog for unsaved changes when closing the email composer.
- Updated localization files to include new strings for draft management.
2026-03-11 18:07:44 +01:00
Linus Rath 723aff38f2 feat: add settings synchronization feature with server, including UI toggle and localization 2026-03-11 16:46:43 +01:00
Linus Rath a8a1ad1d54 feat: implement settings synchronization with server, including encryption and API endpoints 2026-03-11 16:22:47 +01:00
Linus Rath cf5f271be2 Merge branch 'main' of https://github.com/rathlinus/jmap-webmail 2026-03-11 16:04:59 +01:00
Linus Rath d09022378b feat: add devMode configuration to runtime settings and update LoginPage for theme management 2026-03-11 16:01:31 +01:00
Linus Rath 4576d37c7a feat: enhance settings page with mobile layout and add tab groups to localization files 2026-03-11 11:11:27 +01:00
Linus Rath 40e7fa3776 style: format CSS 2026-03-10 16:12:48 +01:00
Linus Rath c1956bfb2d feat: enhance WelcomeBanner with settings button and update styles for better layout 2026-03-10 16:11:14 +01:00
Linus Rath c7485f532c Merge fix/sieve-filter-save-21: fix sieve activation (RFC 9661) - fixes #21 2026-03-10 15:30:24 +01:00
Linus Rath fed508d5b7 fix: demo-only features for gender inference and custom avatars in development 2026-03-09 21:08:45 +01:00
Linus Rath 8985865843 feat: inline search filters, folder icon picker, richer demo data & UI polish
- Replace advanced search panel with inline filter chips and collapsible fields
  (from/to/subject/body inputs, folder dropdown, attachment/flagged/read toggles)
- Add debounced auto-search on filter field changes
- Folder settings: add icon picker for custom per-folder icons, toast feedback,
  reorder sections (folder list first, roles second), empty-state illustration
- Avatar: show sender favicon for company domains, randomuser.me portraits for
  personal emails, custom avatars for demo senders, fallback to initials
- Add /api/favicon proxy route for fetching domain favicons
- Settings store: add senderFavicons toggle and folderIcons persistence
- Richer mock inbox with 11 realistic emails (GitHub, Slack, Stripe, Vercel, etc.)
- Navigation rail & sidebar cleanup, permanent-delete warning i18n (all 8 locales)
- Rename search toggle label to 'More', add body/folder filter translations
2026-03-09 20:59:32 +01:00
Linus Rath d41718768b feat: folder management settings with CRUD and standard role assignment 2026-03-09 19:27:32 +01:00
Linus Rath 481d566eb6 feat: sender favicon avatars 2026-03-09 18:55:44 +01:00
Linus Rath cf02f587de feat: resizable columns, nav rail overhaul, multi-select & drag-drop, UI polish
Resizable Columns
- Add ResizeHandle component with mouse drag, keyboard (Arrow keys),
  and double-click to reset to default width
- Add sidebarWidth/emailListWidth to ui-store with clamping + persistence
- Wire resize handles between sidebar/email-list panels on desktop

Navigation Rail Overhaul
- Move StorageQuotaCircle, push-status, sign-out from Sidebar to NavigationRail
- Interactive SVG ring with popover breakdown (used/free/total)
- Sidebar collapse state lifted to ui-store
- Show total email count per mailbox alongside unread badge

Email Multi-Selection & Drag-and-Drop
- Ctrl+Click (toggle) and Shift+Click (range) on all list items
- Add selectRangeEmails and lastSelectedEmailId to email-store
- Enable drag-and-drop on thread items and thread headers
- useEmailDrag accepts optional threadEmails for full-thread drag

Email Viewer Layout
- Remove card wrapper for cleaner full-width reading
- Always render HTML body when available
- Adjust skeleton loader to match flat layout

Modal & UI Polish
- Standardise backdrops, close buttons, padding, border-radius, transitions
- Migrate template-string classNames to cn() in settings
- Unify focus-ring token to ring-ring on form controls

i18n
- Add storage_used/free/total keys to all 8 locales

Dev Mock JMAP Server (new, gated by DEV_MOCK_JMAP=true)
- Session, Mailbox/Email/Thread/Identity CRUD, back-references, upload
- GET /download with Content-Disposition, GET /eventsource SSE

Tests (46 new)
- ui-store (13), email-selection (10), resize-handle (9), mock-server (14)
2026-03-09 16:46:25 +01:00
236 changed files with 111540 additions and 3813 deletions
+1
View File
@@ -4,6 +4,7 @@ node_modules
.gitignore
.env*
!.env.example
!.env.dev.example
.claude/
scripts/
TODO.md
+49
View File
@@ -0,0 +1,49 @@
# Bulwark Webmail — Development Configuration
# Copy this file to .env.local to run with the built-in mock JMAP server.
# No external mail server required — great for UI development and testing.
#
# Usage:
# cp .env.dev.example .env.local
# npm run dev
# Open http://localhost:3000 — log in with any username/password.
# =============================================================================
# Mock JMAP Server
# =============================================================================
# Enable the built-in mock JMAP server (served at /api/dev-jmap)
DEV_MOCK_JMAP=true
# Point the app at its own mock endpoint.
# IMPORTANT: This must match the origin the app runs on (default: port 3000).
# Using a different port (e.g. 3001) will cause CORS errors.
JMAP_SERVER_URL=/api/dev-jmap
# =============================================================================
# App
# =============================================================================
APP_NAME=Bulwark Webmail (Dev)
# =============================================================================
# Session & Settings Sync (optional for dev)
# =============================================================================
SESSION_SECRET=dev-secret-not-for-production
SETTINGS_SYNC_ENABLED=true
# =============================================================================
# Logging (verbose for development)
# =============================================================================
LOG_FORMAT=text
LOG_LEVEL=debug
# =============================================================================
# Login Page Customization (optional)
# =============================================================================
# LOGIN_COMPANY_NAME=Dev Team
# LOGIN_IMPRINT_URL=https://example.com/imprint
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# LOGIN_WEBSITE_URL=https://example.com
+89 -20
View File
@@ -1,42 +1,111 @@
# JMAP Webmail Configuration
# Copy this file to .env.local and fill in your values
# Bulwark Webmail — Production Configuration
# Copy this file to .env.local and fill in your values.
# For development with the built-in mock server, see .env.dev.example instead.
# =============================================================================
# Runtime Configuration (recommended for Docker)
# These are read at request time, allowing post-build configuration
# JMAP Server (required)
# =============================================================================
# App name displayed in the UI
APP_NAME=JMAP Webmail
APP_NAME=Bulwark Webmail
# JMAP server URL (required)
# This is the URL of your JMAP-compatible mail server
# URL of your JMAP-compatible mail server (required)
JMAP_SERVER_URL=https://your-jmap-server.com
# =============================================================================
# Build-time Configuration (legacy, still supported as fallback)
# These are baked into the bundle at build time
# Stalwart Mail Server Integration
# =============================================================================
# NEXT_PUBLIC_APP_NAME=JMAP Webmail
# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
# Enable Stalwart-specific features (password change, sieve filters, etc.)
# Set to "false" to disable if using a non-Stalwart JMAP server.
# STALWART_FEATURES=true
# If your reverse proxy doesn't forward Stalwart management API paths
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
# STALWART_API_URL=https://admin.example.com
# =============================================================================
# Logging Configuration
# OAuth / OpenID Connect (optional)
# =============================================================================
# Set to "true" to use OAuth instead of basic JMAP authentication
# OAUTH_ENABLED=true
# Set to "true" to only allow OAuth login (hides username/password form)
# Requires OAUTH_ENABLED=true
# OAUTH_ONLY=true
# OAuth client ID registered with your identity provider
# OAUTH_CLIENT_ID=your-client-id
# OAuth client secret (server-side only, never exposed to the browser)
# OAUTH_CLIENT_SECRET=your-client-secret
# OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com
# =============================================================================
# Session & Security
# =============================================================================
# Secret key for encrypting "Remember me" sessions and settings sync data.
# Required for both "Remember me" and settings sync features.
# Generate with: openssl rand -base64 32
# SESSION_SECRET=your-secret-key-here
# =============================================================================
# Settings Sync
# =============================================================================
# Enable server-side settings persistence (requires SESSION_SECRET).
# When enabled, user settings are encrypted and stored on the server,
# allowing them to sync across browsers and devices.
# SETTINGS_SYNC_ENABLED=true
# Directory for storing encrypted settings files (default: ./data/settings).
# For Docker, mount a persistent volume at this path.
# SETTINGS_DATA_DIR=./data/settings
# =============================================================================
# Logging
# =============================================================================
# Log format: "text" (colored, human-readable) or "json" (structured, for log aggregation)
LOG_FORMAT=text
# LOG_FORMAT=text
# Log level: "error", "warn", "info", or "debug"
LOG_LEVEL=info
# LOG_LEVEL=info
# =============================================================================
# Docker Configuration
# Login Page Customization (all optional)
# =============================================================================
# When running with Docker, set these in .env.local:
# APP_NAME=My Webmail
# JMAP_SERVER_URL=https://mail.example.com
# Custom logo images for the login page (PNG, SVG, etc.)
# Can be absolute URLs or paths relative to the public/ directory.
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
#
# Then run:
# docker compose up -d
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
# Company or organization name displayed above the version on the login page
LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the imprint/legal notice link on the login page
# LOGIN_IMPRINT_URL=https://example.com/imprint
# URL for the privacy policy link on the login page
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# URL for the company website link on the login page
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# =============================================================================
# Legacy Build-time Variables (still supported as fallback)
# =============================================================================
# These are baked into the bundle at build time. The runtime variables above
# take priority when both are set.
#
# NEXT_PUBLIC_APP_NAME=Bulwark Webmail
# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
-7
View File
@@ -37,12 +37,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@v3
with:
@@ -55,7 +49,6 @@ jobs:
uses: docker/metadata-action@v5
with:
images: |
rootfr/jmap-webmail
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
+7
View File
@@ -33,6 +33,7 @@ yarn-error.log*
# env files
.env*
!.env.example
!.env.dev.example
# vercel
.vercel
@@ -43,3 +44,9 @@ next-env.d.ts
# claude code
.claude/
# settings sync data
/data/
# Local private data
/local-data/
+103 -1
View File
@@ -1,12 +1,114 @@
# Changelog
## 1.3.0 (2026-03-16)
### Features
- **Calendar**: RSVP support for calendar invitations with trust assessment
- **Calendar**: iCal/webcal subscription support
- **Calendar**: Create, update, and delete calendar events
- **Calendar**: Enhanced EventModal with alert and recurrence labels, view/edit mode toggle
- **Email**: Iframe-based email rendering with smart dark mode support
- **Email**: Security tooltips, contact actions, and scroll improvements in email viewer
- **Email**: Improved message details and contact sidebar in email viewer
- **Email**: Move-to mailbox functionality in email viewer
- **Email**: Mobile bottom action bar with reply and email navigation
- **Email**: Auto-fetch full email content when an email is auto-selected
- **Email**: Unread filter functionality in mailbox sidebar
- **Email**: Empty folder functionality for junk and trash mailboxes with confirmation dialog
- **Files**: JMAP FileNode file storage backend and file settings
- **Files**: File preview support
- **Contacts**: Enhanced contacts management with sidebar and selection features
- **Contacts**: Import/export functionality in contacts settings
- **Contacts**: Improved contact group management with UID normalization
- **Settings**: Tab icons and grouping with improved file settings preview
- **Settings**: Extra-compact density option and font size scaling
- **Settings**: Logout button in settings
- **UI**: Sidebar resizing across calendar, contacts, and settings pages
- **UI**: Keyboard shortcuts button and show/hide toolbar labels option
- **UI**: Recursive depth calculation for mailbox tree structure
- **UI**: Mobile long-press context menu
- **i18n**: Expanded supported locales
- **API**: Enhanced configuration fetching with retry logic
- **License**: Updated to AGPL-3.0-only with NOTICE file for fork lineage
### Fixes
- **Calendar**: Correct all-day multi-day event rendering
- **Email**: Adjust text wrapping for email subject in EmailViewer and ThreadConversationView
- **Email**: Adjust email content area layout for better responsiveness
- **Email**: Sync identity stores and append signatures to outgoing emails (#15)
- **Contacts**: Handle non-string anniversary dates in contact detail
- **UI**: Fix nested button hydration error in sidebar mailbox tree
- **UI**: Update sidebar border styling for consistency across pages
- **UI**: Update avatar background color logic based on image source
- **UI**: Make density setting functional across entire UI
## 1.2.4 (2026-03-14)
### Features
- **Tags**: Show total and unread email counts next to each tag in the sidebar
- **Tags**: Instant tag count refresh after adding or removing a tag
- **Search**: Wildcard query functionality for enhanced search capabilities
- **Search**: Support OR conditions across multiple fields in email search
- **Search**: Improved search results display with pluralization and localization
- **Email**: Dropdown menus for actions in email viewer
- **Email**: Improved email list messages for clarity and localization
- **Email**: Enhanced unsubscribe banner with destructive color styling
- **Auth**: Centralized Stalwart credentials management
- **Login**: Configurable logo with light/dark mode support
- **Avatar**: Dev mode configuration for profile picture retrieval
- **DevOps**: Added `.env.dev.example` for development configuration
### Fixes
- **Email**: Prevent browser auth dialog when viewing emails with inline images
- **Login**: Optimize theme store usage with shallow comparison
- **Git**: Add local private data directory to `.gitignore`
## 1.2.3 (2026-03-13)
### Features
- **Calendar**: Hover functionality for calendar events with preview popover
- **Contacts**: Enhanced contact management and vCard support
- **Email**: Tagging system with color labels and drag-and-drop tag support
- **Email**: Multi-select with checkbox functionality and batch operations
- **Email**: Recipient popover for contact interaction
- **Email**: Inline search filters and folder icon picker
- **Email**: Sender favicon avatars with negative caching for performance
- **Email**: Print functionality for email content
- **Folders**: Folder management settings with CRUD, standard role assignment, and icon picker
- **Folders**: Subfolder creation and hierarchical navigation
- **Settings**: Settings synchronization with server (encrypted API endpoints)
- **Settings**: Toolbar position customization and mobile layout tabs
- **Settings**: Login page customization options
- **Account Security**: Stalwart account security management panel
- **OAuth2/OIDC**: OAuth-only login mode
- **UI**: Resizable columns, navigation rail overhaul, and drag-and-drop email organization
- **UI**: Toast notifications with enter/exit animations and progress bar
- **UI**: Responsive mobile layout with bottom tab bar and tablet support
- **i18n**: Added Dutch and Portuguese translations (now 8 languages)
- **Docker**: Publish only to GHCR, remove Docker Hub
- **DevOps**: Interactive setup script with dry-run option and JMAP server URL validation
- **Branding**: New Bulwark Webmail identity with logo assets and light/dark mode support
### Fixes
- **Sieve**: Use `onSuccessActivateScript` for sieve activation (#21)
- **Composer**: Fix trailing comma handling in recipient input
- **Email**: Fix print functionality for email content
- **Connection**: Connection loss handling with session recovery
- **Redirect**: Login redirect functionality with sessionStorage error handling
## 1.1.2 (2026-03-02)
### Fixes
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/*, globals, @types/node
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
## 1.1.1 (2026-02-28)
+25 -8
View File
@@ -1,23 +1,36 @@
# Contributing to JMAP Webmail
<div align="center">
Thank you for your interest in contributing to JMAP Webmail! This document provides guidelines and information for contributors.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
</picture>
</div>
# Contributing to Bulwark Webmail
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
## Getting Started
### Development Setup
1. **Fork and clone** the repository:
```bash
git clone https://github.com/root-fr/jmap-webmail.git
cd jmap-webmail
git clone https://github.com/bulwarkmail/webmail.git
cd webmail
```
2. **Install dependencies**:
```bash
npm install
```
3. **Set up environment**:
```bash
cp .env.example .env.local
# Edit .env.local with your JMAP server URL
@@ -75,9 +88,10 @@ This project uses **next-intl** for internationalization. Please follow these gu
### Key Rules
1. **Never hardcode user-facing text** - Always use translations:
```tsx
const t = useTranslations('namespace');
return <div>{t('key')}</div>;
const t = useTranslations("namespace");
return <div>{t("key")}</div>;
```
2. **Translation file locations**:
@@ -109,6 +123,7 @@ This project uses **next-intl** for internationalization. Please follow these gu
### Before Submitting
1. **Create a feature branch**:
```bash
git checkout -b feature/your-feature-name
```
@@ -147,6 +162,7 @@ Follow the conventional commits format:
- `chore:` - Maintenance tasks
Examples:
```
feat: add email threading support
fix: resolve attachment download issue
@@ -156,7 +172,7 @@ docs: update README with keyboard shortcuts
## Project Structure
```
jmap-webmail/
webmail/
├── app/ # Next.js App Router pages
│ └── [locale]/ # Locale-aware routing
├── components/ # React components
@@ -184,7 +200,8 @@ jmap-webmail/
## Questions?
If you have questions about contributing, feel free to:
- Open an issue for discussion
- Check existing issues and pull requests
Thank you for helping improve JMAP Webmail!
Thank you for helping improve Bulwark Webmail!
+4 -4
View File
@@ -8,11 +8,11 @@ RUN npx next build --webpack
FROM node:24-alpine AS runner
LABEL org.opencontainers.image.title="JMAP Webmail"
LABEL org.opencontainers.image.title="Bulwark Webmail"
LABEL org.opencontainers.image.description="Modern webmail client built with Next.js and the JMAP protocol"
LABEL org.opencontainers.image.source="https://github.com/root-fr/jmap-webmail"
LABEL org.opencontainers.image.url="https://github.com/root-fr/jmap-webmail"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.source="https://github.com/bulwarkmail/webmail"
LABEL org.opencontainers.image.url="https://github.com/bulwarkmail/webmail"
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"
LABEL org.opencontainers.image.vendor="root.cloud"
WORKDIR /app
+757
View File
@@ -1,3 +1,760 @@
Bulwark Webmail
Copyright (C) 2025-2026 Bulwark Project Authors
This project is licensed under the GNU Affero General Public License,
version 3 only.
This repository includes material derived from earlier MIT-licensed work.
The original attribution notice preserved for the fork lineage is available
in the NOTICE file.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
# Bulwark Webmail fork notice
This repository is distributed under the GNU Affero General Public License,
version 3 only. It also includes material derived from earlier MIT-licensed
work in the fork lineage.
Fork lineage:
- https://github.com/root-fr/jmap-webmail
- https://github.com/bulwarkmail/webmail
Original MIT attribution notice preserved from the earlier forked codebase:
MIT License
Copyright (c) 2025 Matthieu MALVACHE
+34
View File
@@ -0,0 +1,34 @@
# Bulwark Webmail fork notice
This repository is distributed under the GNU Affero General Public License,
version 3 only. It also includes material derived from earlier MIT-licensed
work in the fork lineage.
Fork lineage:
- https://github.com/root-fr/jmap-webmail
- https://github.com/bulwarkmail/webmail
Original MIT attribution notice preserved from the earlier forked codebase:
MIT License
Copyright (c) 2025 Matthieu MALVACHE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+246 -241
View File
@@ -1,181 +1,250 @@
# JMAP Webmail
<div align="center">
A webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
## Why Stalwart?
# Bulwark Webmail
Stalwart is a mail server written in Rust with native JMAP support, not IMAP/SMTP with JMAP added as an afterthought. It handles JMAP, IMAP, SMTP, and ManageSieve. Self-hosted, no third-party dependencies.
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
Built with Next.js and the JMAP protocol.
[Stalwart on GitHub](https://github.com/stalwartlabs/mail-server) | [Documentation](https://stalw.art/docs/)
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.2.4-green.svg)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail)
</div>
---
## Screenshots
<table>
<tr>
<td width="50%">
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
**Mail** — Three-pane layout with sidebar, email list, and viewer
</td>
<td width="50%">
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
**Calendar** — Month, week, day, and agenda views with event management
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
**Contacts** — Contact management with groups and vCard support
</td>
<td width="50%">
<img src="screenshots/files.png" width="100%" alt="File browser">
**Files** — Cloud file browser with upload, preview, and folder navigation
</td>
</tr>
</table>
<details>
<summary>More screenshots</summary>
<table>
<tr>
<td width="50%">
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
**Light mode** — Full theme support with intelligent color transformation
</td>
<td width="50%">
<img src="screenshots/settings.png" width="100%" alt="Settings">
**Settings** — Appearance, identities, filters, templates, and more
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/login.png" width="100%" alt="Login page">
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
</td>
<td width="50%">
</td>
</tr>
</table>
</details>
## Features
### Email
### Mail
- Read, compose, reply, reply-all, and forward
- HTML rendering with DOMPurify sanitization
- Attachment upload and download
- Draft auto-save with discard confirmation
- Threading with inline expansion
- Mark as read/unread, star/unstar
- Archive and delete with configurable behavior
- Color tags/labels
- Search with JMAP filter panel, search chips, cross-mailbox queries
- Virtual scrolling for large lists
### Interface
- Three-pane layout with dark and light themes
- Responsive (desktop sidebar + mobile bottom tab bar)
- Keyboard shortcuts
- Drag-and-drop email organization
- Right-click context menus
- Animations that respect `prefers-reduced-motion`
- Infinite scroll pagination
- Toast notifications with undo support
- Form validation with shake feedback
- Safe area insets for notched devices
- Screen reader live regions
### Real-time
- Push notifications via JMAP EventSource
- Live unread counts
- Email arrival notifications
- Connection status indicator
### Identity management
- Multiple sender identities with per-identity signatures
- Sub-addressing (user+tag@domain.com) with tag suggestions
- Identity badges in viewer and list
### Address book
- Contact management with search and filtering
- JMAP server sync (RFC 9553/9610) with local fallback
- Email autocomplete in composer
- Contact groups with group expansion
- vCard import/export (RFC 6350) with duplicate detection
- Bulk operations (multi-select, delete, group add, export)
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
- **Threading** — Gmail-style inline expansion with thread navigation
- **Draft auto-save** with discard confirmation
- **Attachments** — upload, download, and inline preview
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
- **Print** emails directly from the viewer
- **Color tags/labels** and star/unstar
- **Virtual scrolling** for large mailboxes
- **Quick reply** from the viewer
- **Sender avatars** — favicon-based with negative caching for performance
- **Recipient popover** for quick contact interaction
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
- **Tag counts** — unread and total counts displayed in sidebar
### Calendar
- JMAP Calendar (RFC 8984) with capability detection
- Month, week, day, and agenda views
- Event create, edit, delete with recurrence and reminders
- Participant scheduling with iTIP invitations and RSVP
- Inline calendar invitation banner in email viewer (.ics detection, RSVP, import)
- Multi-day events, column-based overlap layout
- Mini-calendar sidebar with calendar visibility toggles
- Locale-aware date formatting
- Settings for first day of week, time format (12h/24h), default view
- Drag-and-drop rescheduling with time snap
- Click-drag on empty slots to create events
- Resize events by dragging (15-minute snap)
- Double-click quick create
- Event duplication (+1 day offset)
- Recurring event edit/delete scope (this, this and following, all)
- iCalendar (.ics) file import with preview
- Real-time updates via JMAP push
- Event notifications with configurable sound
- **Month, week, day, and agenda views** with mini-calendar sidebar
- **Event hover preview** popover with details
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
- **Recurring events** with edit/delete scope (this / this and following / all)
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
- **iCalendar import** with preview and bulk create
- **Notifications** with configurable sound and alert persistence
- **Real-time sync** via JMAP push
### Email templates
### Contacts
- Reusable templates organized by category
- Placeholder variables (`{{recipientName}}`, `{{date}}`, etc.) with auto-fill
- Template picker in compose toolbar with search and filter
- Template manager in settings
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
- **Contact groups** with group expansion and member management
- **vCard import/export** (RFC 6350) with duplicate detection
- **Autocomplete** in composer (To/Cc/Bcc)
- **Bulk operations** — multi-select, delete, group add, export
### Email filters
### Filters & Automation
- Server-side filtering with JMAP Sieve Scripts (RFC 9661)
- Visual rule builder: conditions (From, To, Subject, Size, Body...) and actions (Move, Forward, Mark read, Star, Discard, Reject...)
- Raw Sieve editor with syntax validation
- Auto-save with rollback on failure
- Drag-and-drop rule reordering
- Only shown when the server supports Sieve
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
- **Visual rule builder** — conditions (From, To, Subject, Size, Body) and actions (Move, Forward, Star, Discard)
- **Raw Sieve editor** with syntax validation
- **Vacation responder** with date range scheduling and sidebar indicator
- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
### Vacation responder
### Files
- JMAP VacationResponse with date range scheduling
- Settings tab for message configuration
- Sidebar indicator when active
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
- **Upload and download** files with progress tracking and folder upload support
- **Folder navigation** with breadcrumb path and tree sidebar
- **Grid and list views** with sorting by name, size, or date
- **Clipboard operations** — cut, copy, paste, duplicate files
- **File preview** for images, text, audio, video, and more
- **Favorites and recent files** for quick access
- **Bulk operations** — multi-select, delete, move, download
### Security and privacy
### Security & Privacy
- External content blocked by default
- Trusted senders list for automatic image loading
- HTML sanitization (DOMPurify)
- SPF/DKIM/DMARC status indicators
- Session-based auth, no password storage by default
- TOTP two-factor authentication
- "Remember me" with AES-256-GCM encrypted httpOnly cookie (opt-in)
- OAuth2/OIDC with PKCE for SSO (opt-in, RP-initiated logout)
- External IdP support (Keycloak, Authentik) via configurable issuer URL
- CORS misconfiguration detection with detailed error messages
- Shared folder support
- Newsletter unsubscribe (RFC 2369)
- CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy headers
- **External content blocked** by default — trusted senders list for auto-load
- **HTML sanitization** via DOMPurify with XSS prevention
- **SPF/DKIM/DMARC** status indicators
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode
- **TOTP two-factor authentication**
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
- **Newsletter unsubscribe** (RFC 2369)
### Interface
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
- **Dark and light themes** with intelligent email color transformation
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** — full navigation without a mouse
- **Drag-and-drop** email organization between mailboxes and tag assignment
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
- **Customizable toolbar** position and login page branding
- **Configurable logo** with light/dark mode variants
- **Settings sync** — preferences synchronized with the server (encrypted)
- **Storage quota** display
- **Shared folders** — multi-account access
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
### Internationalization
- 8 languages: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese
- Automatic browser language detection
- Persistent language preference
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
### Deployment
Automatic browser detection with persistent preference.
- Pre-built Docker images on [Docker Hub](https://hub.docker.com/r/rootfr/jmap-webmail) and [GHCR](https://ghcr.io/root-fr/jmap-webmail) (amd64/arm64)
- Multi-stage build with standalone output
- Runtime environment variables (no rebuild needed)
- Health check endpoint
- Structured logging (text/JSON)
- Update check on startup (server logs only, no client exposure)
### Identity Management
## Tech stack
- **Multiple sender identities** with per-identity signatures
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
- **Identity badges** in viewer and email list
- [Next.js 16](https://nextjs.org/) with App Router
- TypeScript
- [Tailwind CSS v4](https://tailwindcss.com/)
- [Zustand](https://zustand-demo.pmnd.rs/) for state management
- Custom JMAP client (RFC 8620)
- [next-intl](https://next-intl-docs.vercel.app/) for i18n
- [Lucide React](https://lucide.dev/) icons
### Operations
## Getting started
- **Automatic update check** — server logs when a newer release is available
### Prerequisites
---
- Node.js 18+
- A JMAP-compatible mail server ([Stalwart](https://stalw.art/) recommended)
## Quick Start
### Installation
### Docker (recommended)
```bash
git clone https://github.com/root-fr/jmap-webmail.git
cd jmap-webmail
npm install
cp .env.example .env.local
docker run -d -p 3000:3000 \
-e JMAP_SERVER_URL=https://mail.example.com \
ghcr.io/bulwarkmail/webmail:latest
```
### Configuration
Or with Docker Compose:
```bash
cp .env.example .env.local
# Edit .env.local — set JMAP_SERVER_URL
docker compose up -d
```
### From Source
```bash
git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
cp .env.example .env.local
# Edit .env.local — set JMAP_SERVER_URL
npm run build && npm start
```
### Development
```bash
npm run dev # Start dev server (mock JMAP server included)
npm run typecheck # Type checking
npm run lint # Linting
```
## Configuration
Edit `.env.local`:
```env
# App name displayed in the UI
APP_NAME=My Webmail
# Your JMAP server URL (required)
# Required
JMAP_SERVER_URL=https://mail.example.com
# Optional
APP_NAME=My Webmail
```
These are runtime environment variables, read at request time. Docker deployments can be configured without rebuilding. Legacy `NEXT_PUBLIC_*` variables still work as fallbacks.
All variables are **runtime** — Docker deployments can be configured without rebuilding.
#### OAuth2/OIDC (optional)
To enable SSO login alongside Basic Auth:
<details>
<summary>OAuth2/OIDC (SSO)</summary>
```env
OAUTH_ENABLED=true
@@ -184,118 +253,52 @@ OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. If your JMAP server delegates auth to an external IdP, set `OAUTH_ISSUER_URL` to the IdP's base URL (e.g., `https://keycloak.example.com/realms/mail`).
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
#### Remember me (optional)
</details>
To enable "Remember me" for Basic Auth login:
<details>
<summary>Remember Me</summary>
```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
```
When set, a "Remember me" checkbox appears on the login form. Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry).
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
### Development
</details>
```bash
npm run dev # Start dev server
npm run typecheck # Type checking
npm run lint # Linting
```
## Keyboard Shortcuts
### Production
| Key | Action |
| ------------- | ----------------------- |
| `j` / `k` | Navigate between emails |
| `Enter` / `o` | Open email |
| `Esc` | Close / deselect |
| `c` | Compose |
| `r` / `R` | Reply / Reply all |
| `f` | Forward |
| `s` | Star |
| `e` | Archive |
| `#` | Delete |
| `/` | Search |
| `?` | Show all shortcuts |
```bash
npm run build
npm start
```
## Tech Stack
### Docker
| | |
| ------------- | ------------------------------------------------- |
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router |
| **Language** | TypeScript |
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
| **Protocol** | Custom JMAP client (RFC 8620) |
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
| **Icons** | [Lucide React](https://lucide.dev/) |
```bash
# Pre-built image
docker run -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com rootfr/jmap-webmail:latest
## Why Stalwart?
# From GHCR
docker run -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com ghcr.io/root-fr/jmap-webmail:latest
# With docker compose
cp .env.example .env.local
# Edit .env.local with your JMAP_SERVER_URL
docker compose up -d
# Build from source
docker build -t jmap-webmail .
docker run -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com jmap-webmail
```
## Keyboard shortcuts
| Key | Action |
|-----|--------|
| `j` / `k` | Navigate between emails |
| `Enter` / `o` | Open selected email |
| `Esc` | Close viewer / deselect |
| `c` | Compose new email |
| `r` | Reply |
| `R` / `a` | Reply all |
| `f` | Forward |
| `s` | Toggle star |
| `e` | Archive |
| `#` / `Delete` | Delete |
| `u` | Mark as unread |
| `/` | Focus search |
| `x` | Expand/collapse thread |
| `Ctrl+Shift+T` | Insert template |
| `?` | Show shortcuts help |
## Screenshots
<table>
<tr>
<td width="50%">
**Login**
<img src="screenshots/01-login.png" width="100%" alt="Login">
</td>
<td width="50%">
**Inbox**
<img src="screenshots/02-inbox.png" width="100%" alt="Inbox">
</td>
</tr>
<tr>
<td width="50%">
**Email Viewer**
<img src="screenshots/03-email-viewer.png" width="100%" alt="Email Viewer">
</td>
<td width="50%">
**Compose**
<img src="screenshots/04-compose.png" width="100%" alt="Compose">
</td>
</tr>
<tr>
<td width="50%">
**Dark Mode**
<img src="screenshots/05-dark-mode.png" width="100%" alt="Dark Mode">
</td>
<td width="50%">
**Settings**
<img src="screenshots/06-settings.png" width="100%" alt="Settings">
</td>
</tr>
</table>
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
## Contributing
@@ -303,13 +306,15 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Roadmap
See [ROADMAP.md](ROADMAP.md) for planned features.
## Acknowledgments
- [Stalwart Labs](https://stalw.art/) for the mail server
- The [JMAP](https://jmap.io/) working group for the protocol spec
See [ROADMAP.md](ROADMAP.md) for planned features and current status.
## License
MIT. See [LICENSE](LICENSE).
[GNU AGPL v3](LICENSE)
This repository also preserves the original MIT attribution notice for the
fork lineage in [NOTICE](NOTICE).
## Acknowledgments
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon.
+35 -3
View File
@@ -1,16 +1,28 @@
# JMAP Webmail - Roadmap
<div align="center">
This document tracks the development status and planned features for JMAP Webmail.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" />
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" />
</picture>
</div>
# Bulwark Webmail - Roadmap
This document tracks the development status and planned features for Bulwark Webmail.
## Completed Features
### Core Infrastructure
- [x] Next.js 16 with TypeScript and App Router
- [x] Tailwind CSS v4 with Oxide engine
- [x] Zustand state management
- [x] Custom JMAP client implementation (RFC 8620)
### Authentication
- [x] Login with JMAP server authentication
- [x] Session management (no password storage for security)
- [x] Username autocomplete with history
@@ -23,6 +35,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] "Remember me" session persistence for Basic Auth (AES-256-GCM encrypted httpOnly cookie)
### JMAP Server Connection
- [x] Session establishment and keep-alive
- [x] Connection error handling and retries
- [x] CORS error detection with actionable user guidance
@@ -32,6 +45,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Shared folders support (multi-account access)
### Email Operations
- [x] Email fetching and display
- [x] Full HTML email rendering
- [x] Compose, reply, reply-all, forward
@@ -48,6 +62,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Email threading (Gmail-style inline expansion)
### Real-time Updates
- [x] EventSource for JMAP push notifications
- [x] State synchronization
- [x] Email arrival notifications
@@ -55,6 +70,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Mailbox change handling
### User Interface
- [x] Three-pane layout (sidebar, list, viewer)
- [x] Minimalist design system
- [x] Dark and light theme support
@@ -86,6 +102,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Screen reader live region announcements (sr-only)
### Internationalization
- [x] English language support
- [x] French language support
- [x] Japanese language support
@@ -98,6 +115,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Language preference persistence
### Security & Accessibility
- [x] External content blocked by default
- [x] HTML sanitization with DOMPurify
- [x] User control for loading external content
@@ -114,6 +132,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Screen reader sr-only live region for dynamic announcements
### Identity Management
- [x] Multiple sender identities (name, email, signature)
- [x] Sub-addressing support (user+tag@domain.com)
- [x] Per-identity signatures
@@ -123,6 +142,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Primary identity (matching login) selected by default in composer
### Address Book & Contacts
- [x] Contact store with JMAP sync and local fallback
- [x] Contact CRUD operations (create, read, update, delete)
- [x] Contacts list view with search/filter
@@ -136,12 +156,14 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] i18n support for contacts (all 8 languages)
### Vacation Responder
- [x] JMAP VacationResponse singleton management
- [x] Settings tab with date range and message configuration
- [x] Sidebar indicator when vacation auto-reply is active
- [x] i18n support (all 8 languages)
### Calendar Integration
- [x] JMAP Calendar types (RFC 8984) and client methods
- [x] Calendar capability detection (urn:ietf:params:jmap:calendars)
- [x] Calendar store with Zustand (persist middleware)
@@ -177,6 +199,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Event duplication button in modal (clones event +1 day, opens for editing)
### Email Filters
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
- [x] Visual rule builder (conditions: From/To/Cc/Subject/Header/Size/Body, actions: Move/Copy/Forward/Mark read/Star/Label/Discard/Reject/Keep/Stop)
- [x] Raw Sieve script editor with syntax validation
@@ -189,6 +212,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] i18n support (all 8 languages)
### Email Templates
- [x] Reusable email templates with local storage persistence
- [x] Category organization (General, Business, Personal, Support, Follow-up, custom)
- [x] Dynamic placeholder variables with auto-fill from composer context
@@ -200,10 +224,12 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] i18n support (all 8 languages)
### Email Display
- [x] Proper email layout without horizontal scroll or clipping
- [x] Blocked image container collapsing (no empty spaces in newsletters)
### Testing
- [x] Unit tests for validation utilities (57 tests)
- [x] Unit tests for email sanitization (27 tests)
- [x] Unit tests for color transformation (40 tests)
@@ -226,11 +252,12 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Playwright E2E framework setup
### Deployment
- [x] Runtime environment variables (Docker-friendly configuration)
- [x] Health check endpoint
- [x] Docker support (multi-stage build, docker-compose, standalone output)
- [x] Structured server-side logger (text/JSON format, configurable level)
- [x] Pre-built Docker image on [Docker Hub](https://hub.docker.com/r/rootfr/jmap-webmail) and [GHCR](https://ghcr.io/root-fr/jmap-webmail) with multi-arch support (amd64/arm64)
- [x] Pre-built Docker image on [Docker Hub](https://hub.docker.com/r/bulwarkmail/webmail) and [GHCR](https://ghcr.io/bulwarkmail/webmail) with multi-arch support (amd64/arm64)
- [x] GitHub Actions CI/CD for automated image publishing on releases
- [x] CVE remediation: remove npm from production image, upgrade Alpine packages
- [x] Server-side update check (logs newer version availability on startup)
@@ -238,26 +265,31 @@ This document tracks the development status and planned features for JMAP Webmai
## Planned Features
### Advanced Features
- [ ] Free/busy queries (Principal/getAvailability)
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
- [ ] Email encryption (PGP/GPG)
### Performance Optimizations
- [ ] Email content caching
- [ ] Bundle size optimization
- [ ] Service worker for offline support
- [ ] Lazy loading for attachments
### Testing (Remaining)
- [ ] E2E tests with real JMAP server
- [ ] Accessibility testing
- [ ] Performance testing
### Deployment
- [ ] Production build optimizations
- [ ] Monitoring and logging
### Security Enhancements
- [ ] Rate limiting
## Known Issues
+1 -1
View File
@@ -1 +1 @@
1.1.2
1.3.0
+9 -1
View File
@@ -53,7 +53,15 @@ function OAuthCallbackInner() {
sessionStorage.removeItem("oauth_state");
sessionStorage.removeItem("oauth_code_verifier");
sessionStorage.removeItem("oauth_server_url");
router.push(`/${params.locale}`);
let redirectTo = `/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
} else {
setError("token_exchange_failed");
}
+200 -30
View File
@@ -1,8 +1,9 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { Plus } from "lucide-react";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
@@ -10,10 +11,12 @@ import {
} from "date-fns";
import { useCalendarStore } from "@/stores/calendar-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
@@ -24,8 +27,11 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
import { EventModal } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { debug } from "@/lib/debug";
@@ -42,12 +48,15 @@ export default function CalendarPage() {
const router = useRouter();
const t = useTranslations("calendar");
const isMobile = useIsMobile();
const { client, isAuthenticated } = useAuthStore();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat } = useSettingsStore();
const { identities } = useIdentityStore();
@@ -59,6 +68,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -68,13 +78,31 @@ export default function CalendarPage() {
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
const hasFetched = useRef(false);
// Sidebar resize state
const [calSidebarWidth, setCalSidebarWidth] = useState(() => {
try { const v = localStorage.getItem("calendar-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
// Check auth on mount
useEffect(() => {
if (!isAuthenticated) {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login");
} else if (!supportsCalendar) {
} else if (client && !supportsCalendar) {
router.push("/");
}
}, [isAuthenticated, supportsCalendar, router]);
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]);
useEffect(() => {
if (error) {
@@ -89,6 +117,16 @@ export default function CalendarPage() {
}
}, [client, fetchCalendars]);
// Auto-refresh iCal subscriptions
useEffect(() => {
if (!client) return;
// Refresh on mount (respects per-subscription interval)
refreshAllSubscriptions(client);
// Check again every 5 minutes
const interval = setInterval(() => refreshAllSubscriptions(client), 5 * 60 * 1000);
return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]);
const dateRange = useMemo(() => {
const d = selectedDate;
switch (viewMode) {
@@ -155,10 +193,35 @@ export default function CalendarPage() {
setMiniMonth(new Date());
}, [setSelectedDate]);
// Swipe navigation handlers for mobile
const handleTouchStart = useCallback((e: ReactTouchEvent) => {
const touch = e.touches[0];
touchStartRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
}, []);
const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
if (!touchStartRef.current || !isMobile) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y;
const elapsed = Date.now() - touchStartRef.current.time;
touchStartRef.current = null;
// Only trigger swipe if horizontal movement is dominant and fast enough
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
if (dx > 0) navigatePrev();
else navigateNext();
}
}, [isMobile, navigatePrev, navigateNext]);
const handleSelectDate = useCallback((date: Date) => {
setSelectedDate(date);
setMiniMonth(date);
}, [setSelectedDate]);
// On mobile month view, tapping a date switches to day view
if (isMobile && viewMode === "month") {
setViewMode("day");
}
}, [setSelectedDate, isMobile, viewMode, setViewMode]);
const handleMiniMonthChange = useCallback((date: Date) => {
setMiniMonth(date);
@@ -178,16 +241,35 @@ export default function CalendarPage() {
setShowEventModal(true);
}, []);
const handleSelectEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, []);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeDetail = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
setDetailEvent(null);
setDetailAnchorRect(null);
}, []);
const handleSelectEvent = useCallback((event: CalendarEvent, _anchorRect: DOMRect) => {
// Click opens the sidebar for viewing/editing
closeDetail();
openEditModal(event);
}, [closeDetail, openEditModal]);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return;
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, [showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => {
hoverTimerRef.current = setTimeout(() => {
setDetailEvent(null);
setDetailAnchorRect(null);
}, 200);
}, []);
const handleEditFromDetail = useCallback(() => {
if (detailEvent) {
const ev = detailEvent;
@@ -548,7 +630,10 @@ export default function CalendarPage() {
calendars={calendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
/>
);
case "week":
@@ -559,9 +644,12 @@ export default function CalendarPage() {
calendars={calendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
isMobile={isMobile}
/>
);
case "day":
@@ -571,8 +659,11 @@ export default function CalendarPage() {
events={visibleEvents}
calendars={calendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onCreateAtTime={openCreateModal}
timeFormat={timeFormat}
isMobile={isMobile}
/>
);
case "agenda":
@@ -582,6 +673,8 @@ export default function CalendarPage() {
events={visibleEvents}
calendars={calendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
timeFormat={timeFormat}
/>
);
@@ -601,14 +694,60 @@ export default function CalendarPage() {
};
return (
<div className="flex h-screen bg-background">
<div className="flex h-dvh bg-background overflow-hidden">
{/* Left Navigation Rail */}
{!isMobile && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center py-3 flex-shrink-0">
<NavigationRail collapsed className="py-0" />
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
/>
</div>
)}
{/* Sidebar - full height */}
{!isMobile && (
<>
<div
className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${calSidebarWidth}px` }}
>
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
/>
<CalendarSidebarPanel
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => {
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
client={client}
/>
</div>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
}}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
</>
)}
<div className="flex flex-col flex-1 min-w-0">
<CalendarToolbar
selectedDate={selectedDate}
@@ -619,29 +758,49 @@ export default function CalendarPage() {
onViewModeChange={setViewMode}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
/>
<div className="flex flex-1 overflow-hidden">
{!isMobile && (
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleSelectDate}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
/>
<CalendarSidebarPanel
<div
className="flex flex-1 overflow-hidden relative"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{renderView()}
{/* Desktop event panel */}
{!isMobile && showEventModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<EventModal
event={editEvent}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
currentUserEmails={currentUserEmails}
isMobile={false}
/>
</div>
)}
{renderView()}
{/* Floating Create Event Button (mobile) */}
{isMobile && (
<Button
onClick={() => openCreateModal()}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={t("events.create")}
>
<Plus className="h-6 w-6" />
</Button>
)}
</div>
{/* Mobile Bottom Navigation */}
@@ -661,12 +820,15 @@ export default function CalendarPage() {
onClose={closeDetail}
onSaveNote={handleSaveNoteFromDetail}
onRsvp={handleRsvpFromDetail}
onMouseEnter={() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }}
onMouseLeave={handleHoverLeave}
currentUserEmails={currentUserEmails}
timeFormat={timeFormat}
isMobile={isMobile}
/>
)}
{showEventModal && (
{showEventModal && isMobile && (
<EventModal
event={editEvent}
calendars={calendars}
@@ -678,6 +840,7 @@ export default function CalendarPage() {
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
currentUserEmails={currentUserEmails}
isMobile={true}
/>
)}
@@ -689,6 +852,13 @@ export default function CalendarPage() {
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
onClose={() => setShowSubscriptionModal(false)}
/>
)}
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
+178 -142
View File
@@ -3,23 +3,24 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
import { ArrowLeft, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ContactList } from "@/components/contacts/contact-list";
import { ContactDetail } from "@/components/contacts/contact-detail";
import { ContactForm } from "@/components/contacts/contact-form";
import { ContactGroupList } from "@/components/contacts/contact-group-list";
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import type { ContactCard } from "@/lib/jmap/types";
@@ -31,23 +32,22 @@ type View =
| "group-detail"
| "group-create"
| "group-edit"
| "import"
| "bulk-add-to-group";
export default function ContactsPage() {
const router = useRouter();
const t = useTranslations("contacts");
const { client, isAuthenticated } = useAuthStore();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
contacts,
selectedContactId,
searchQuery,
supportsSync,
activeTab,
selectedContactIds,
setSelectedContact,
setSearchQuery,
setActiveTab,
fetchContacts,
createContact,
updateContact,
@@ -62,24 +62,47 @@ export default function ContactsPage() {
removeMembersFromGroup,
deleteGroup,
toggleContactSelection,
selectRangeContacts,
selectAllContacts,
clearSelection,
bulkDeleteContacts,
bulkAddToGroup,
importContacts,
} = useContactStore();
const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
// Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; }
});
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
const sidebarDragStartWidth = useRef(180);
// Panel resize state - contact list
const [listWidth, setListWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
});
const [isListResizing, setIsListResizing] = useState(false);
const listDragStartWidth = useRef(320);
// Check auth on mount
useEffect(() => {
if (!isAuthenticated) {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login");
}
}, [isAuthenticated, router]);
}, [initialCheckDone, isAuthenticated, authLoading, router]);
useEffect(() => {
if (client && supportsSync && !hasFetched.current) {
@@ -94,6 +117,30 @@ export default function ContactsPage() {
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
// Contacts to display based on active category
const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals;
// Show members of the selected group
return getGroupMembers(activeCategory.groupId);
}, [activeCategory, individuals, getGroupMembers]);
// Label for the current category
const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all");
const group = contacts.find(c => c.id === activeCategory.groupId);
return group ? getContactDisplayName(group) : t("tabs.all");
}, [activeCategory, contacts, t]);
const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category);
clearSelection();
if (typeof category === "object") {
setSelectedGroupId(category.groupId);
} else {
setSelectedGroupId(null);
}
}, [clearSelection]);
const handleSelectContact = (id: string) => {
setSelectedContact(id);
clearSelection();
@@ -166,8 +213,6 @@ export default function ContactsPage() {
const handleCancel = () => {
if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list");
} else if (view === "import") {
setView("list");
} else if (view === "bulk-add-to-group") {
setView("list");
} else {
@@ -177,6 +222,7 @@ export default function ContactsPage() {
const handleSelectGroup = (id: string) => {
setSelectedGroupId(id);
setActiveCategory({ groupId: id });
setView("group-detail");
};
@@ -215,11 +261,10 @@ export default function ContactsPage() {
const jmapClient = supportsSync && client ? client : null;
if (view === "group-edit" && selectedGroup) {
await updateGroup(jmapClient, selectedGroup.id, name);
const currentMemberIds = selectedGroup.members
? Object.keys(selectedGroup.members).filter(k => selectedGroup.members![k])
: [];
const toAdd = memberIds.filter(id => !currentMemberIds.includes(id));
const toRemove = currentMemberIds.filter(id => !memberIds.includes(id));
// Use resolved member contact IDs for diff, not raw urn:uuid: keys
const currentIds = selectedGroupMembers.map(m => m.id);
const toAdd = memberIds.filter(id => !currentIds.includes(id));
const toRemove = currentIds.filter(id => !memberIds.includes(id));
if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd);
if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove);
toast.success(t("toast.updated"));
@@ -229,7 +274,7 @@ export default function ContactsPage() {
toast.success(t("toast.created"));
setView("list");
}
}, [view, selectedGroup, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
}, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
const handleRemoveGroupMember = async (memberId: string) => {
if (!selectedGroup) return;
@@ -303,13 +348,6 @@ export default function ContactsPage() {
}
};
const handleImport = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
if (!isAuthenticated) return null;
const renderRightPanel = () => {
@@ -336,9 +374,10 @@ export default function ContactsPage() {
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember}
isMobile={isMobile}
onSelectMember={(id) => {
setSelectedContact(id);
setActiveTab("all");
setActiveCategory("all");
setView("detail");
}}
/>
@@ -365,15 +404,6 @@ export default function ContactsPage() {
/>
);
case "import":
return (
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={handleCancel}
/>
);
case "bulk-add-to-group":
return (
<div className="flex flex-col h-full">
@@ -422,127 +452,133 @@ export default function ContactsPage() {
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
isMobile={isMobile}
/>
);
}
};
const showListPanel = !isMobile || view === "list";
const showRightPanel = !isMobile || view !== "list";
const mobileBackToList = () => {
setView("list");
clearSelection();
};
return (
<div className="flex h-screen bg-background">
<div className="flex h-dvh bg-background overflow-hidden">
{/* Navigation Rail - desktop only */}
{!isMobile && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
<NavigationRail collapsed />
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
/>
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
<div className="w-80 border-r border-border flex flex-col flex-shrink-0">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setView("import")}
title={t("import.title")}
{showListPanel && (
<>
{/* Panel 1: Categories sidebar */}
{!isMobile && (
<>
<div
className={cn(
"border-r border-border flex flex-col flex-shrink-0",
!isSidebarResizing && "transition-[width] duration-300"
)}
style={{ width: `${sidebarWidth}px` }}
>
<ContactsSidebar
groups={groups}
individuals={individuals}
activeCategory={activeCategory}
onSelectCategory={handleSelectCategory}
onCreateGroup={handleCreateGroup}
onCreateContact={handleCreateNew}
/>
</div>
<ResizeHandle
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsSidebarResizing(false);
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }}
/>
</>
)}
{/* Panel 2: Contact list */}
<div
className={cn(
"border-r border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "",
!isListResizing && !isMobile && "transition-[width] duration-300"
)}
style={!isMobile ? { width: `${listWidth}px` } : undefined}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
if (contacts.length > 0) {
exportContacts(contacts.filter(c => c.kind !== "group"));
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
}
}}
title={t("export.title")}
>
<Download className="w-4 h-4" />
</Button>
<ContactList
contacts={displayedContacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
categoryLabel={categoryLabel}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
onSelectRangeContacts={selectRangeContacts}
onSelectAll={selectAllContacts}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport}
/>
</div>
{!isMobile && (
<ResizeHandle
onResizeStart={() => { listDragStartWidth.current = listWidth; setIsListResizing(true); }}
onResize={(delta) => setListWidth(Math.max(220, Math.min(500, listDragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsListResizing(false);
localStorage.setItem("contacts-list-width", String(listWidth));
}}
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
/>
)}
</>
)}
{/* Panel 3: Detail / Form */}
{showRightPanel && (
<div className="flex-1 min-w-0 flex flex-col">
{isMobile && (
<div className="px-3 py-2 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={mobileBackToList}
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
</div>
)}
<div className="flex-1 min-h-0">
{renderRightPanel()}
</div>
</div>
</div>
</div>
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("all")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
activeTab === "all"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<BookUser className="w-4 h-4" />
{t("tabs.all")}
</button>
<button
onClick={() => setActiveTab("groups")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
activeTab === "groups"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<Users className="w-4 h-4" />
{t("tabs.groups")}
{groups.length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted">
{groups.length}
</span>
)}
</button>
</div>
{activeTab === "all" ? (
<ContactList
contacts={contacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
onImport={() => setView("import")}
supportsSync={supportsSync}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
onSelectAll={selectAllContacts}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport}
/>
) : (
<ContactGroupList
groups={groups}
selectedGroupId={selectedGroupId}
onSelectGroup={handleSelectGroup}
onCreateGroup={handleCreateGroup}
searchQuery={searchQuery}
className="flex-1"
/>
)}
</div>
<div className="flex-1 min-w-0">
{renderRightPanel()}
</div>
)}
</div>
{isMobile && (
+463
View File
@@ -0,0 +1,463 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { useIsMobile } from "@/hooks/use-media-query";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
currentPath,
resources,
isLoading,
error,
supportsFiles,
selectedResources,
uploadProgress,
clipboard,
initClient,
checkSupport,
navigate,
navigateByPath,
refresh,
createDirectory,
uploadFile,
uploadFiles,
uploadFolder,
deleteResource,
deleteResources,
renameResource,
downloadResource,
getImageUrl,
getFileContent,
createTextFile,
duplicateResource,
downloadResources,
moveToFolder,
moveToParent,
cutResources,
copyResources,
pasteResources,
selectResource,
toggleSelect,
selectAll,
clearSelection,
setSelection,
listPath,
listByParentId,
favorites,
recentFiles,
toggleFavorite,
addRecentFile,
cancelUpload,
undoLastAction,
lastAction,
} = useFileStore();
const isMobile = useIsMobile();
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const hasFetched = useRef(false);
// Sync folderLayout when settings change
useEffect(() => {
const reload = () => setFolderLayout(loadFilesSettings().folderLayout);
const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); };
window.addEventListener("storage", handleStorage);
window.addEventListener("files-settings-changed", reload);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener("files-settings-changed", reload);
};
}, []);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<string | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [detailName, setDetailName] = useState<string | null>(null);
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
// Check auth on mount
useEffect(() => {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
// Redirect if not authenticated
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login");
}
}, [initialCheckDone, isAuthenticated, authLoading, router]);
// Initialize JMAP files client
useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) {
hasFetched.current = true;
initClient(client);
}
}, [isAuthenticated, client, initClient]);
// Check support and load root after client is initialized
const storeClient = useFileStore(s => s.client);
useEffect(() => {
if (storeClient && supportsFiles === null) {
checkSupport().then((supported) => {
if (supported) {
navigate(null);
}
});
}
}, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || '');
} else {
// Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path);
}
}, [navigate, navigateByPath]);
const handleCreateFolder = useCallback(async (name: string) => {
try {
await createDirectory(name);
toast.success(t("create_folder_success"));
} catch (err) {
console.error("Failed to create folder:", err);
toast.error(t("create_folder_error"));
}
}, [createDirectory, t]);
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
const handleUploadFiles = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
}
if (valid.length === 0) return;
try {
await uploadFiles(valid);
toast.success(t("upload_success", { count: valid.length }));
} catch (err) {
console.error("Failed to upload files:", err);
toast.error(t("upload_error"));
}
}, [uploadFiles, t]);
const handleUploadFolder = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
}
if (valid.length === 0) return;
try {
await uploadFolder(valid);
toast.success(t("upload_success", { count: valid.length }));
} catch (err) {
console.error("Failed to upload folder:", err);
toast.error(t("upload_error"));
}
}, [uploadFolder, t]);
const handleDelete = useCallback(async (name: string) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm_message", { name }),
confirmText: t("delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteResource(name);
toast.success(t("delete_success"));
} catch (err) {
console.error("Failed to delete:", err);
toast.error(t("delete_error"));
}
}, [deleteResource, confirmDialog, t]);
const handleBatchDelete = useCallback(async (names: string[]) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("batch_delete_confirm_message", { count: names.length }),
confirmText: t("delete"),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteResources(names);
toast.success(t("batch_delete_success", { count: names.length }));
} catch (err) {
console.error("Failed to batch delete:", err);
toast.error(t("delete_error"));
}
}, [deleteResources, confirmDialog, t]);
const handleUndo = useCallback(async () => {
try {
await undoLastAction();
toast.success(t("undo_success"));
} catch (err) {
console.error("Failed to undo:", err);
toast.error(t("undo_error"));
}
}, [undoLastAction, t]);
const handleRename = useCallback(async (oldName: string, newName: string) => {
try {
await renameResource(oldName, newName);
toast.success(t("rename_success"), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to rename:", err);
toast.error(t("rename_error"));
}
}, [renameResource, t, handleUndo]);
const findResourceId = useCallback((name: string) => {
const r = resources.find(res => res.name === name);
return r?.id || name;
}, [resources]);
const handleDownload = useCallback(async (name: string) => {
try {
await downloadResource(name);
addRecentFile(name, findResourceId(name));
} catch (err) {
console.error("Failed to download:", err);
toast.error(t("download_error"));
}
}, [downloadResource, addRecentFile, findResourceId, t]);
const handleBatchDownload = useCallback(async (names: string[]) => {
try {
await downloadResources(names);
} catch (err) {
console.error("Failed to batch download:", err);
toast.error(t("download_error"));
}
}, [downloadResources, t]);
const handleCreateTextFile = useCallback(async (name: string) => {
try {
await createTextFile(name);
toast.success(t("create_file_success"));
} catch (err) {
console.error("Failed to create file:", err);
toast.error(t("create_file_error"));
}
}, [createTextFile, t]);
const handleDuplicate = useCallback(async (name: string) => {
try {
await duplicateResource(name);
toast.success(t("duplicate_success"));
} catch (err) {
console.error("Failed to duplicate:", err);
toast.error(t("duplicate_error"));
}
}, [duplicateResource, t]);
const handleMoveToFolder = useCallback(async (names: string[], targetFolder: string) => {
try {
await moveToFolder(names, targetFolder);
toast.success(t("move_success", { count: names.length }), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to move:", err);
toast.error(t("move_error"));
}
}, [moveToFolder, t, handleUndo]);
const handleMoveToParent = useCallback(async (names: string[]) => {
try {
await moveToParent(names);
toast.success(t("move_success", { count: names.length }), {
action: { label: t("undo"), onClick: handleUndo },
});
} catch (err) {
console.error("Failed to move:", err);
toast.error(t("move_error"));
}
}, [moveToParent, t, handleUndo]);
const handlePaste = useCallback(async () => {
try {
await pasteResources();
toast.success(t("paste_success"), {
action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined,
});
} catch (err) {
console.error("Failed to paste:", err);
toast.error(t("paste_error"));
}
}, [pasteResources, t, lastAction, handleUndo]);
const handlePreviewImage = useCallback((name: string) => {
setPreviewImage(name);
addRecentFile(name, findResourceId(name));
}, [addRecentFile, findResourceId]);
const handlePreviewFile = useCallback((name: string) => {
setPreviewFile(name);
addRecentFile(name, findResourceId(name));
}, [addRecentFile, findResourceId]);
const handleShowDetails = useCallback((name: string) => {
setDetailName(name);
setShowDetails(true);
}, []);
const handleToggleDetails = useCallback(() => {
setShowDetails(v => !v);
}, []);
if (!isAuthenticated) return null;
return (
<div className="flex h-dvh bg-background overflow-hidden">
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
/>
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
<div className="flex-1 min-w-0 flex flex-col">
{folderLayout !== "sidebar" && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("title")}
</Button>
</div>
</div>
)}
<div className="flex-1 min-h-0">
{supportsFiles === false ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div>
) : (
<FileBrowser
currentPath={currentPath}
resources={resources}
isLoading={isLoading}
error={error}
selectedResources={selectedResources}
uploadProgress={uploadProgress}
clipboard={clipboard}
onNavigate={handleNavigate}
onCreateFolder={handleCreateFolder}
onUploadFiles={handleUploadFiles}
onUploadFolder={handleUploadFolder}
onCancelUpload={cancelUpload}
onDelete={handleDelete}
onBatchDelete={handleBatchDelete}
onRename={handleRename}
onDownload={handleDownload}
onBatchDownload={handleBatchDownload}
onRefresh={refresh}
onSelectResource={selectResource}
onToggleSelect={toggleSelect}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onSetSelection={setSelection}
onCut={cutResources}
onCopy={copyResources}
onPaste={handlePaste}
onMoveToFolder={handleMoveToFolder}
onMoveToParent={handleMoveToParent}
onPreviewImage={handlePreviewImage}
onPreviewFile={handlePreviewFile}
onShowDetails={handleShowDetails}
onCreateTextFile={handleCreateTextFile}
onDuplicate={handleDuplicate}
getImageUrl={getImageUrl}
listPath={listPath}
listByParentId={listByParentId}
favorites={favorites}
recentFiles={recentFiles}
onToggleFavorite={toggleFavorite}
showDetails={showDetails}
onToggleDetails={handleToggleDetails}
detailResource={detailResource}
/>
)}
</div>
</div>
</div>
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
</div>
{/* Image preview modal */}
{previewImage && (
<ImagePreviewModal
name={previewImage}
onClose={() => setPreviewImage(null)}
onDownload={handleDownload}
getImageUrl={getImageUrl}
/>
)}
{/* File preview modal (text, PDF, audio, video, markdown) */}
{previewFile && (
<FilePreviewModal
name={previewFile}
onClose={() => setPreviewFile(null)}
onDownload={() => handleDownload(previewFile)}
getFileContent={() => getFileContent(previewFile)}
/>
)}
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
+499 -239
View File
@@ -1,25 +1,37 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "@/i18n/navigation";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn } from "lucide-react";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.2.4";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
{ value: "dark" as const, icon: Moon, label: "Dark" },
{ value: "system" as const, icon: Monitor, label: "System" },
];
export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
const params = useParams();
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, rememberMeEnabled, isLoading: configLoading, error: configError } = useConfig();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
username: "",
@@ -31,6 +43,7 @@ export default function LoginPage() {
const [sessionExpired, setSessionExpired] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [showThemeMenu, setShowThemeMenu] = useState(false);
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
@@ -45,6 +58,11 @@ export default function LoginPage() {
const justSelectedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
initializeTheme();
}, [initializeTheme]);
useEffect(() => {
if (serverUrl) {
@@ -85,7 +103,15 @@ export default function LoginPage() {
useEffect(() => {
if (isAuthenticated) {
router.push('/');
let redirectTo = '/';
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
}
}, [isAuthenticated, router]);
@@ -122,6 +148,9 @@ export default function LoginPage() {
inputRef.current && !inputRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
setShowThemeMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
@@ -141,9 +170,14 @@ export default function LoginPage() {
});
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
setTheme(newTheme);
setShowThemeMenu(false);
}, [setTheme]);
if (configLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto" />
<span className="sr-only">{t("loading")}</span>
@@ -154,15 +188,17 @@ export default function LoginPage() {
if (configError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl p-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-500/10 mb-5">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("config_error.fetch_failed")}
</p>
</div>
<h1 className="text-xl font-medium text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm">
{t("config_error.fetch_failed")}
</p>
</div>
</div>
);
@@ -170,15 +206,17 @@ export default function LoginPage() {
if (!serverUrl) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl p-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-500/10 mb-5">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-semibold text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("config_error.server_not_configured")}
</p>
</div>
<h1 className="text-xl font-medium text-foreground mb-2">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm">
{t("config_error.server_not_configured")}
</p>
</div>
</div>
);
@@ -291,240 +329,462 @@ export default function LoginPage() {
if (success) {
saveUsername(formData.username);
router.push('/');
let redirectTo = '/';
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
}
};
const handleDevLogin = async () => {
const success = await login(serverUrl, "dev@localhost", "dev");
if (success) {
let redirectTo = '/';
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
}
};
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
const CurrentThemeIcon = currentThemeOption.icon;
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4">
{/* Logo */}
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-6 shadow-lg shadow-primary/5">
<Mail className="w-10 h-10 text-primary" />
</div>
<h1 className="text-3xl font-light text-foreground tracking-tight">
{appName}
</h1>
</div>
{/* Session Expired Banner */}
{sessionExpired && (
<div
className="mb-6 p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg flex items-start gap-3"
role="status"
aria-live="polite"
>
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1">
{t("session_expired")}
</p>
<button
type="button"
onClick={() => setSessionExpired(false)}
className="p-0.5 rounded hover:bg-blue-500/10 transition-colors flex-shrink-0"
aria-label={t("dismiss")}
>
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</button>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">
{error === 'invalid_credentials' && showTotpField && totpCode
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
{/* Login Form */}
<form
onSubmit={handleSubmit}
className={cn("space-y-4", shakeError && "animate-shake")}
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle - top right, dropdown style */}
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
<button
type="button"
onClick={() => setShowThemeMenu(!showThemeMenu)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
showThemeMenu
? "bg-secondary border-border text-foreground shadow-md"
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
)}
aria-label={`Theme: ${currentThemeOption.label}`}
aria-expanded={showThemeMenu}
aria-haspopup="listbox"
>
<fieldset disabled={isLoading} className="space-y-4">
<div className="relative">
<Input
ref={inputRef}
id="username"
type="text"
value={formData.username}
onChange={handleUsernameChange}
onFocus={handleUsernameFocus}
onKeyDown={handleKeyDown}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("username_placeholder")}
required
autoComplete="off"
data-form-type="other"
data-lpignore="true"
autoFocus
/>
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
</button>
{/* Custom autocomplete dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute top-full mt-1 w-full bg-secondary border border-border rounded-md shadow-lg z-50 overflow-hidden"
{showThemeMenu && (
<div
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="listbox"
aria-label="Theme selection"
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon;
const isActive = theme === option.value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isActive}
onClick={() => handleThemeSelect(option.value)}
className={cn(
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
isActive
? "bg-primary/10 text-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={cn(
"px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
index === selectedSuggestionIndex && "bg-muted"
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
})}
</div>
)}
</div>
<div className="w-full max-w-[400px] mx-auto">
{/* Card container */}
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
{/* Header section with logo */}
<div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
alt={appName}
className="max-w-16 max-h-16 object-contain"
/>
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{appName}
</h1>
<p className="text-sm text-muted-foreground mt-1.5">
{t("title") !== appName ? t("title") : "Sign in to your account"}
</p>
</div>
{/* Form section */}
<div className="px-8 pb-8">
{/* Session Expired Banner */}
{sessionExpired && (
<div
className="mb-5 p-3.5 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-start gap-3"
role="status"
aria-live="polite"
>
<Info className="w-4.5 h-4.5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1 leading-relaxed">
{t("session_expired")}
</p>
<button
type="button"
onClick={() => setSessionExpired(false)}
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0"
aria-label={t("dismiss")}
>
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</button>
</div>
)}
{/* Error Message */}
{error && (
<div className={cn(
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
shakeError && "animate-shake"
)}>
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
{error === 'invalid_credentials' && showTotpField && totpCode
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
{/* Dev Mode: One-click login */}
{devMode ? (
<div className="space-y-4">
<Button
type="button"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/20"
onClick={handleDevLogin}
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in")}
</div>
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
Dev mode logging in as dev@localhost
</p>
</div>
) : oauthOnly ? (
/* OAuth-only mode: show SSO button only */
<div className="space-y-4">
{oauthMetadata ? (
<Button
type="button"
className="w-full h-11 font-medium text-[15px] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
onClick={handleOAuthLogin}
disabled={oauthLoading}
>
{oauthLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in_sso")}
</div>
)}
</Button>
) : oauthDiscoveryDone ? (
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
</p>
</div>
) : (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
)}
</div>
) : (
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4">
{/* Username field */}
<div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground">
{t("username_label")}
</label>
<div className="relative">
<Input
ref={inputRef}
id="username"
type="text"
value={formData.username}
onChange={handleUsernameChange}
onFocus={handleUsernameFocus}
onKeyDown={handleKeyDown}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("username_placeholder")}
required
autoComplete="off"
data-form-type="other"
data-lpignore="true"
autoFocus
/>
{/* Custom autocomplete dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute top-full mt-1.5 w-full bg-background border border-border rounded-xl shadow-lg z-50 overflow-hidden"
>
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={cn(
"px-3.5 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
index === selectedSuggestionIndex && "bg-muted"
)}
onClick={() => selectSuggestion(username)}
>
<span className="text-sm text-foreground">{username}</span>
<button
type="button"
onClick={(e) => removeUsername(username, e)}
className="p-1 hover:bg-secondary rounded-md transition-colors"
title={t("remove_from_history")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</div>
)}
onClick={() => selectSuggestion(username)}
>
<span className="text-sm text-foreground">{username}</span>
</div>
</div>
{/* Password field */}
<div className="space-y-1.5">
<label htmlFor="password" className="block text-sm font-medium text-foreground">
{t("password_label")}
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
<button
type="button"
onClick={(e) => removeUsername(username, e)}
className="p-1 hover:bg-background rounded transition-colors"
title={t("remove_from_history")}
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
<X className="w-3 h-3 text-muted-foreground" />
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
))}
</div>
</div>
{/* 2FA toggle / field */}
{!showTotpField ? (
<button
type="button"
onClick={() => {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 50);
}}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Shield className="w-3.5 h-3.5" />
{t("totp_toggle")}
</button>
) : (
<div className="space-y-1.5">
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
{t("totp_label")}
</label>
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
aria-label={t("totp_label")}
/>
</div>
)}
{/* Remember me */}
{rememberMeEnabled && (
<label className="flex items-center gap-2.5 cursor-pointer group select-none pt-1">
<span className="relative flex items-center justify-center">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="peer sr-only"
/>
<span className="flex items-center justify-center w-[18px] h-[18px] rounded-[5px] border border-border/80 bg-muted/40 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background transition-all duration-200">
{rememberMe && (
<svg className="w-3 h-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</span>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
{t("remember_me")}
</span>
</label>
)}
</fieldset>
<Button
type="submit"
className="w-full h-11 font-medium text-[15px] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in")}
</div>
)}
</Button>
{oauthMetadata && (
<>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/60" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background/80 px-3 text-muted-foreground">{t("or")}</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleOAuthLogin}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
)}
{t("sign_in_sso")}
</Button>
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
</p>
</div>
)}
</form>
)}
</div>
</div>
{/* Company name & links - below card */}
<div className="mt-6 flex flex-col items-center gap-2">
{loginCompanyName && (
<p className="text-center text-xs text-muted-foreground/60 font-medium">
{loginCompanyName}
</p>
)}
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
<div className="flex items-center gap-3 flex-wrap justify-center">
{loginWebsiteUrl && (
<a
href={loginWebsiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("website")}
</a>
)}
{loginImprintUrl && (
<a
href={loginImprintUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("imprint")}
</a>
)}
{loginPrivacyPolicyUrl && (
<a
href={loginPrivacyPolicyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{t("privacy_policy")}
</a>
)}
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-12 px-4 pr-11 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-4.5 h-4.5" />
) : (
<Eye className="w-4.5 h-4.5" />
)}
</button>
</div>
{!showTotpField ? (
<button
type="button"
onClick={() => {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 50);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
>
{t("totp_toggle")}
</button>
) : (
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-10 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
aria-label={t("totp_label")}
/>
)}
{rememberMeEnabled && (
<label className="flex items-center gap-2.5 cursor-pointer group select-none">
<span className="relative flex items-center justify-center">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="peer sr-only"
/>
<span className="flex items-center justify-center w-4.5 h-4.5 rounded border border-border bg-secondary/50 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background transition-colors">
{rememberMe && (
<svg className="w-3 h-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</span>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
{t("remember_me")}
</span>
</label>
)}
</fieldset>
<Button
type="submit"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
t("sign_in")
)}
</Button>
{oauthMetadata && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">{t("or")}</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full h-12 font-medium text-base"
onClick={handleOAuthLogin}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
)}
{t("sign_in_sso")}
</Button>
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
</p>
</div>
)}
</form>
<p className="text-center text-xs text-muted-foreground/40">
v{APP_VERSION}
</p>
</div>
</div>
</div>
);
+627 -93
View File
File diff suppressed because it is too large Load Diff
+320 -44
View File
@@ -1,9 +1,29 @@
"use client";
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
import {
ArrowLeft,
ChevronRight,
LogOut,
Settings as SettingsIcon,
Palette,
Mail,
User,
Shield,
UserPen,
PalmtreeIcon,
Calendar,
Filter,
FileText,
FolderOpen,
Tags,
HardDrive,
Wrench,
BookUser,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
@@ -11,40 +31,276 @@ import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'advanced';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef {
id: Tab;
label: string;
icon: LucideIcon;
group: TabGroup;
}
const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
account: User,
security: Shield,
identities: UserPen,
vacation: PalmtreeIcon,
calendar: Calendar,
contacts: BookUser,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
files: HardDrive,
advanced: Wrench,
};
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
export default function SettingsPage() {
const router = useRouter();
const t = useTranslations('settings');
const { client } = useAuthStore();
const [activeTab, setActiveTab] = useState<Tab>('appearance');
const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const [activeTab, setActiveTab] = useState<Tab>(() => {
try {
const saved = localStorage.getItem('settings-active-tab');
if (saved) return saved as Tab;
} catch { /* ignore */ }
return 'appearance';
});
const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop();
// Sidebar resize state
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Check auth on mount
useEffect(() => {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push('/login');
}
}, [initialCheckDone, isAuthenticated, authLoading, router]);
if (!isAuthenticated) {
return null;
}
const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: { id: Tab; label: string }[] = [
{ id: 'appearance', label: t('tabs.appearance') },
{ id: 'email', label: t('tabs.email') },
{ id: 'account', label: t('tabs.account') },
{ id: 'identities', label: t('tabs.identities') },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []),
{ id: 'templates', label: t('tabs.templates') },
{ id: 'advanced', label: t('tabs.advanced') },
const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
];
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
setMobileShowContent(true);
}
};
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
const renderTabContent = () => (
<>
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <ContactsSettings />}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'advanced' && <AdvancedSettings />}
</>
);
// Mobile layout
if (!isDesktop) {
// Mobile: show content view
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile content header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileShowContent(false)}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()}
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail orientation="horizontal" />
</div>
);
}
// Mobile: show tab list
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/')}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
<h1 className="font-semibold text-lg">{t('title')}</h1>
</div>
</div>
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
<div className="py-2">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
<div className="px-5 pt-3 pb-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
<span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" />
{tab.label}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
))}
</div>
{/* Logout */}
<div className="border-t border-border px-5 py-3">
<button
onClick={() => { logout(); router.push('/login'); }}
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
>
<LogOut className="w-4 h-4" />
<span>{tSidebar('sign_out')}</span>
</button>
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail orientation="horizontal" />
</div>
);
}
// Desktop layout
return (
<div className="flex h-screen bg-background">
<div className="flex h-dvh bg-background">
{/* Navigation Rail */}
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
/>
</div>
{/* Settings Sidebar */}
<div className="w-64 border-r border-border bg-secondary flex flex-col">
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
{/* Header */}
<div className="p-4 border-b border-border">
<Button
@@ -60,47 +316,67 @@ export default function SettingsPage() {
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded text-sm transition-colors',
activeTab === tab.id
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted text-foreground'
)}
>
{tab.label}
</button>
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
activeTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
{/* Sidebar resize handle */}
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth));
}}
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
{/* Settings Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<SettingsIcon className="w-8 h-8 text-foreground" />
<h1 className="text-3xl font-semibold text-foreground">{t('title')}</h1>
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
</div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6">
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <CalendarSettings />}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
{renderTabContent()}
</div>
</div>
</div>
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/auth
* Proxy to Stalwart GET /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart auth info failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch auth info', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/auth
* Proxy to Stalwart POST /api/account/auth
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart auth update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/crypto
* Proxy to Stalwart GET /api/account/crypto
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart crypto info failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch crypto info', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/account/stalwart/crypto
* Proxy to Stalwart POST /api/account/crypto
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
method: 'POST',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart crypto update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
};
/**
* POST /api/account/stalwart/password
* Change user password via Stalwart PATCH /api/principal/{name}
*
* Body: { currentPassword: string, newPassword: string }
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
// Verify current password by attempting to authenticate
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': verifyAuth },
});
if (!verifyResponse.ok) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
}
// Change password via Stalwart principal API
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify([
{ action: 'set', field: 'secrets', value: newPassword },
]),
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart password change failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to change password', details: text },
{ status: response.status }
);
}
// If session cookie exists, update it with the new password
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/principal
* Proxy to Stalwart GET /api/principal/{username}
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
if (!response.ok) {
const text = await response.text();
logger.warn('Stalwart principal fetch failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch principal', details: text },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/account/stalwart/principal
* Proxy to Stalwart PATCH /api/principal/{username}
* Body: PrincipalUpdateAction[] (array of {action, field, value})
*/
export async function PATCH(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json();
// Prevent secrets field from being changed through this endpoint (use /password instead)
if (Array.isArray(body)) {
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
if (hasSecrets) {
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
}
}
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
method: 'PATCH',
headers: {
'Authorization': creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
logger.warn('Stalwart principal update failed', { status: response.status });
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/account/stalwart/probe
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwart: false });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json({ isStalwart: false });
}
const data = await response.json();
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
return NextResponse.json({ isStalwart });
} catch {
clearTimeout(timeout);
return NextResponse.json({ isStalwart: false });
}
} catch (error) {
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ isStalwart: false });
}
}
+4
View File
@@ -14,6 +14,10 @@ const COOKIE_OPTIONS = {
export async function POST(request: NextRequest) {
try {
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
}
const { serverUrl, username, password } = await request.json();
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
+10
View File
@@ -19,8 +19,18 @@ export async function GET() {
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
oauthOnly: process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true',
oauthClientId: process.env.OAUTH_CLIENT_ID || '',
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
rememberMeEnabled: !!process.env.SESSION_SECRET,
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET,
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false',
devMode: process.env.DEV_MOCK_JMAP === 'true',
loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg',
loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg',
loginCompanyName: process.env.LOGIN_COMPANY_NAME || '',
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
});
}
File diff suppressed because one or more lines are too long
+185
View File
@@ -0,0 +1,185 @@
import { NextRequest, NextResponse } from 'next/server';
// In-memory LRU cache: domain -> { data, contentType, fetchedAt }
const CACHE_MAX_SIZE = 1000;
const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 2 weeks
interface CacheEntry {
data: ArrayBuffer;
contentType: string;
fetchedAt: number;
}
interface NegativeCacheEntry {
fetchedAt: number;
}
const cache = new Map<string, CacheEntry>();
const negativeCache = new Map<string, NegativeCacheEntry>();
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
const NEGATIVE_CACHE_MAX_SIZE = 2000;
// Strict domain validation to prevent SSRF
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
function isValidDomain(domain: string): boolean {
if (domain.length > 253) return false;
if (!DOMAIN_RE.test(domain)) return false;
// Block internal/private hostnames
const lower = domain.toLowerCase();
if (
lower === 'localhost' ||
lower.endsWith('.local') ||
lower.endsWith('.internal') ||
lower.endsWith('.arpa')
) {
return false;
}
return true;
}
// Known multi-part TLDs where the registrable domain includes one extra label.
const MULTI_PART_TLDS = new Set([
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk",
"co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
"co.kr", "or.kr", "go.kr", "ac.kr",
"co.in", "net.in", "org.in", "ac.in", "gov.in",
"co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz",
"co.za", "org.za", "net.za", "gov.za", "ac.za",
"com.au", "net.au", "org.au", "edu.au", "gov.au",
"com.br", "net.br", "org.br", "edu.br", "gov.br",
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
"com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx",
"com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar",
"com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw",
"com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk",
"com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg",
"com.my", "net.my", "org.my", "edu.my", "gov.my",
"com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph",
"com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk",
"com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng",
"co.il", "org.il", "net.il", "ac.il", "gov.il",
"co.th", "or.th", "ac.th", "go.th", "in.th",
"co.id", "or.id", "ac.id", "go.id", "web.id",
"com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr",
"com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua",
"com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg",
"com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa",
"co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke",
]);
function getRootDomain(domain: string): string {
const parts = domain.split(".");
if (parts.length <= 2) return domain;
const lastTwo = parts.slice(-2).join(".");
if (MULTI_PART_TLDS.has(lastTwo)) {
return parts.length >= 3 ? parts.slice(-3).join(".") : domain;
}
return parts.slice(-2).join(".");
}
function evictOldest() {
if (cache.size < CACHE_MAX_SIZE) return;
// Evict the oldest entry
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of cache) {
if (entry.fetchedAt < oldestTime) {
oldestTime = entry.fetchedAt;
oldestKey = key;
}
}
if (oldestKey) cache.delete(oldestKey);
}
export async function GET(request: NextRequest) {
const domain = request.nextUrl.searchParams.get('domain');
if (!domain || !isValidDomain(domain)) {
return new NextResponse(null, {
status: 400,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
}
// Resolve to root domain so subdomains share the same favicon lookup
const normalizedDomain = getRootDomain(domain.toLowerCase());
// Check negative cache (domains known to have no favicon)
const neg = negativeCache.get(normalizedDomain);
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
});
}
// Check cache
const cached = cache.get(normalizedDomain);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return new NextResponse(cached.data, {
headers: {
'Content-Type': cached.contentType,
'Cache-Control': 'public, max-age=1209600', // 2 weeks
},
});
}
try {
const upstream = await fetch(
`https://icons.duckduckgo.com/ip3/${encodeURIComponent(normalizedDomain)}.ico`,
{ signal: AbortSignal.timeout(5000) }
);
if (!upstream.ok) {
evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
}
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
const data = await upstream.arrayBuffer();
// Don't cache empty/tiny responses (likely no real favicon)
if (data.byteLength < 10) {
evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
}
// Cache the result
evictOldest();
cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() });
return new NextResponse(data, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=1209600',
},
});
} catch {
return new NextResponse(null, {
status: 502,
headers: { 'Cache-Control': 'public, max-age=300' }, // 5 min
});
}
}
function evictNegativeOldest() {
if (negativeCache.size < NEGATIVE_CACHE_MAX_SIZE) return;
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of negativeCache) {
if (entry.fetchedAt < oldestTime) {
oldestTime = entry.fetchedAt;
oldestKey = key;
}
}
if (oldestKey) negativeCache.delete(oldestKey);
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false;
}
const hostname = url.hostname.toLowerCase();
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
) {
return false;
}
// Block URLs with credentials
if (url.username || url.password) {
return false;
}
return true;
}
export async function POST(request: NextRequest) {
let body: { url?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { url } = body;
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidExternalUrl(url)) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'follow',
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json(
{ error: `Remote server returned ${response.status}` },
{ status: 502 }
);
}
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
return NextResponse.json({ error: 'File too large' }, { status: 413 });
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_RESPONSE_SIZE) {
return NextResponse.json({ error: 'File too large' }, { status: 413 });
}
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'text/calendar',
'Content-Length': buffer.byteLength.toString(),
},
});
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
}
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
}
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
}
/**
* Verify identity against the session cookie if available.
* Returns true if no session cookie exists (can't verify) or if identity matches.
* Returns false if session cookie exists but identity doesn't match.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies();
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies)
const session = decryptSession(sessionToken);
if (!session) return true; // Invalid session cookie, skip verification
return session.username === username && session.serverUrl === serverUrl;
}
export async function GET(request: NextRequest) {
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
}
const username = request.headers.get('x-settings-username');
const serverUrl = request.headers.get('x-settings-server');
if (!username || !serverUrl) {
return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 });
}
if (!(await verifyIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
try {
const settings = await loadUserSettings(username, serverUrl);
if (!settings) {
return NextResponse.json({ error: 'No settings found' }, { status: 404 });
}
return NextResponse.json({ settings });
} catch (error) {
logger.error('Settings load error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
}
try {
const { username, serverUrl, settings } = await request.json();
if (!username || !serverUrl || !settings) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
return NextResponse.json({ error: 'Settings must be an object' }, { status: 400 });
}
if (!(await verifyIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
await saveUserSettings(username, serverUrl, settings);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Settings save error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
}
try {
const { username, serverUrl } = await request.json();
if (!username || !serverUrl) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (!(await verifyIdentity(username, serverUrl))) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
await deleteUserSettings(username, serverUrl);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Settings delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
/**
* POST /api/webdav
* Proxies WebDAV requests to the Stalwart server.
*
* Headers:
* X-WebDAV-Method: The actual WebDAV method (PROPFIND, MKCOL, GET, PUT, DELETE, MOVE, COPY)
* X-WebDAV-Path: Resource path relative to the user's DAV root (default: /)
* X-WebDAV-Destination: Destination path for MOVE/COPY (relative to user's DAV root)
* Depth: WebDAV Depth header (forwarded as-is)
* Content-Type: Forwarded for PROPFIND (XML) and PUT (file upload)
* Overwrite: WebDAV Overwrite header for MOVE/COPY
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const method = request.headers.get('X-WebDAV-Method')?.toUpperCase();
if (!method || !ALLOWED_METHODS.has(method)) {
return NextResponse.json({ error: 'Invalid WebDAV method' }, { status: 400 });
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
// Build headers for the upstream request
const upstreamHeaders: Record<string, string> = {
'Authorization': creds.authHeader,
};
// Forward relevant WebDAV headers
const depth = request.headers.get('Depth');
if (depth) upstreamHeaders['Depth'] = depth;
const contentType = request.headers.get('Content-Type');
if (contentType) upstreamHeaders['Content-Type'] = contentType;
// For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination');
if (destination) {
const cleanDest = destination.replace(/^\/+/, '');
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
}
const overwrite = request.headers.get('Overwrite');
if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
// Forward request body for methods that need it
let body: ArrayBuffer | null = null;
if (method === 'PROPFIND' || method === 'PUT') {
body = await request.arrayBuffer();
}
const response = await fetch(targetUrl, {
method,
headers: upstreamHeaders,
body,
redirect: 'follow',
});
// For file downloads (GET), stream the response back
if (method === 'GET') {
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
const contentLength = response.headers.get('Content-Length');
if (contentLength) headers.set('Content-Length', contentLength);
headers.set('X-WebDAV-Request-URI', targetUrl);
return new NextResponse(response.body, {
status: response.status,
headers,
});
}
// For PROPFIND, return XML with the actual request URI for href comparison
if (method === 'PROPFIND') {
const text = await response.text();
const headers = new Headers();
headers.set('Content-Type', 'application/xml; charset=utf-8');
headers.set('X-WebDAV-Request-URI', targetUrl);
return new NextResponse(text, {
status: response.status,
headers,
});
}
// For other methods (MKCOL, DELETE, MOVE, COPY, PUT), return the status
return new NextResponse(null, {
status: response.status,
});
} catch (error) {
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+99 -15
View File
@@ -3,6 +3,8 @@
@custom-variant dark (&:where(.dark, .dark *));
:root {
font-size: var(--font-size-base);
--color-border: #e2e8f0;
--color-input: #e2e8f0;
--color-ring: #94a3b8;
@@ -16,11 +18,20 @@
--color-muted-foreground: #64748b;
--color-accent: #dbeafe;
--color-accent-foreground: #1e40af;
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
/* Settings variables */
--font-size-base: 16px;
--list-item-height: 48px;
--transition-duration: 0.2s;
/* Density spacing variables (defaults = regular) */
--density-item-py: 12px;
--density-item-gap: 12px;
--density-header-py: 12px;
--density-card-p: 16px;
--density-sidebar-py: 4px;
}
.dark {
@@ -37,6 +48,8 @@
--color-muted-foreground: #a3a3a3;
--color-accent: #1e3a8a;
--color-accent-foreground: #dbeafe;
--color-destructive: #ef4444;
--color-destructive-foreground: #fafafa;
}
@theme inline {
@@ -53,6 +66,8 @@
--color-muted-foreground: var(--color-muted-foreground);
--color-accent: var(--color-accent);
--color-accent-foreground: var(--color-accent-foreground);
--color-destructive: var(--color-destructive);
--color-destructive-foreground: var(--color-destructive-foreground);
}
* {
@@ -62,9 +77,13 @@
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: system-ui, -apple-system, sans-serif;
font-size: var(--font-size-base);
font-feature-settings: "rlig" 1, "calt" 1;
font-family:
system-ui,
-apple-system,
sans-serif;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
/* Minimalist scrollbar */
@@ -92,14 +111,26 @@ body {
min-width: 100%;
}
.email-content-text {
padding: 1rem 1.25rem;
}
.email-content {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-foreground);
max-width: none;
overflow-wrap: break-word;
word-wrap: break-word;
padding: 1rem 1.25rem;
}
/* Remove padding for rich HTML emails with table-based layouts */
.email-content:has(> table) {
padding: 0;
}
.email-content p {
@@ -143,7 +174,7 @@ body {
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
padding: 1rem;
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
font-size: 0.875rem;
overflow-x: auto;
margin: 1rem 0;
@@ -158,7 +189,7 @@ body {
background-color: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
font-size: 0.875rem;
color: #dc2626;
}
@@ -179,10 +210,18 @@ body {
color: var(--color-foreground);
}
.email-content h1 { font-size: 1.5rem; }
.email-content h2 { font-size: 1.25rem; }
.email-content h3 { font-size: 1.125rem; }
.email-content h4 { font-size: 1rem; }
.email-content h1 {
font-size: 1.5rem;
}
.email-content h2 {
font-size: 1.25rem;
}
.email-content h3 {
font-size: 1.125rem;
}
.email-content h4 {
font-size: 1rem;
}
.email-content ul,
.email-content ol {
@@ -203,6 +242,26 @@ body {
list-style-type: decimal;
}
/* Reply quoted HTML - preserves original inline styles/colors */
.email-reply-quote {
overflow-wrap: break-word;
word-wrap: break-word;
max-width: none;
}
.email-reply-quote p {
margin: 0.5rem 0;
}
.email-reply-quote img {
max-width: 100%;
height: auto;
}
.email-reply-quote a {
text-decoration: underline;
}
/* Only style tables that are actual data tables, not layout tables */
.email-content table.data-table,
.email-content table[border="1"] {
@@ -281,9 +340,9 @@ body {
}
/* Toast animations */
@keyframes slide-in {
@keyframes toast-enter {
from {
transform: translateX(100%);
transform: translateX(calc(100% + 1.25rem));
opacity: 0;
}
to {
@@ -292,10 +351,33 @@ body {
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
@keyframes toast-exit {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(calc(100% + 1.25rem));
opacity: 0;
}
}
@keyframes toast-progress {
from {
width: 100%;
}
to {
width: 0%;
}
}
.toast-enter {
animation: toast-enter 0.32s cubic-bezier(0.21, 1.02, 0.73, 1) forwards;
}
.toast-exit {
animation: toast-exit 0.28s cubic-bezier(0.06, 0.71, 0.55, 1) forwards;
}
/* Mobile Responsive Utilities */
@@ -331,7 +413,9 @@ body {
/* Smooth transitions for view switching */
.view-transition {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
transition:
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
}
/* Hide scrollbar on mobile while keeping functionality */
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Gravit.io -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -15,7 +15,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "JMAP Webmail",
title: "Bulwark Webmail",
description: "Minimalist webmail client using JMAP protocol",
};
+17 -27
View File
@@ -6,7 +6,7 @@ import { format, parseISO, isToday, isTomorrow } from "date-fns";
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate } from "@/lib/calendar-utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -15,6 +15,8 @@ interface CalendarAgendaViewProps {
events: CalendarEvent[];
calendars: Calendar[];
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
timeFormat?: "12h" | "24h";
}
@@ -28,6 +30,8 @@ export function CalendarAgendaView({
events,
calendars,
onSelectEvent,
onHoverEvent,
onHoverLeave,
timeFormat = "24h",
}: CalendarAgendaViewProps) {
const t = useTranslations("calendar");
@@ -49,35 +53,18 @@ export function CalendarAgendaView({
sorted.forEach((ev) => {
try {
const start = new Date(ev.start);
const end = getEventEndDate(ev);
const startKey = format(start, "yyyy-MM-dd");
const endKey = format(end, "yyyy-MM-dd");
if (startKey === endKey || ev.showWithoutTime) {
let group = groupMap.get(startKey);
const { startDay, endDay } = getEventDayBounds(ev);
const cursor = new Date(startDay);
while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd");
let group = groupMap.get(key);
if (!group) {
group = { date: start, dateKey: startKey, events: [] };
groupMap.set(startKey, group);
group = { date: new Date(cursor), dateKey: key, events: [] };
groupMap.set(key, group);
groups.push(group);
}
group.events.push(ev);
} else {
const cursor = new Date(start);
cursor.setHours(0, 0, 0, 0);
const endDay = new Date(end);
endDay.setHours(0, 0, 0, 0);
while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd");
let group = groupMap.get(key);
if (!group) {
group = { date: new Date(cursor), dateKey: key, events: [] };
groupMap.set(key, group);
groups.push(group);
}
group.events.push(ev);
cursor.setDate(cursor.getDate() + 1);
}
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip invalid dates */ }
});
@@ -140,7 +127,10 @@ export function CalendarAgendaView({
<button
key={ev.id}
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
className="w-full flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
{ev.showWithoutTime ? (
+21 -11
View File
@@ -6,7 +6,7 @@ import { format, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
import { getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
@@ -15,8 +15,11 @@ interface CalendarDayViewProps {
events: CalendarEvent[];
calendars: Calendar[];
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
}
const HOUR_HEIGHT = 64;
@@ -27,8 +30,11 @@ export function CalendarDayView({
events,
calendars,
onSelectEvent,
onHoverEvent,
onHoverLeave,
onCreateAtTime,
timeFormat = "24h",
isMobile,
}: CalendarDayViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -46,10 +52,7 @@ export function CalendarDayView({
const allDay: CalendarEvent[] = [];
events.forEach((ev) => {
try {
const start = new Date(ev.start);
const end = getEventEndDate(ev);
const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
const endDay = new Date(end); endDay.setHours(0, 0, 0, 0);
const { startDay, endDay } = getEventDayBounds(ev);
const selDay = new Date(selectedDate); selDay.setHours(0, 0, 0, 0);
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
@@ -110,9 +113,12 @@ export function CalendarDayView({
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className="px-4 py-3 border-b border-border">
<h3 className={cn("text-lg font-semibold", today && "text-primary")}>
{intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile
? intlFormatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
: intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
}
</h3>
</div>
@@ -129,6 +135,8 @@ export function CalendarDayView({
calendar={calendarMap.get(calId)}
variant="chip"
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
/>
);
})}
@@ -138,15 +146,15 @@ export function CalendarDayView({
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="w-16 flex-shrink-0">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-3"
className="relative text-muted-foreground text-right pr-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className="absolute top-0 right-3 -translate-y-1/2 text-xs leading-none">
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[10px]" : "text-xs")}>
{formatHour(h)}
</span>
)}
@@ -200,6 +208,8 @@ export function CalendarDayView({
calendar={calendarMap.get(calId)}
variant="block"
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
draggable
/>
<div
+84 -33
View File
@@ -8,7 +8,7 @@ import {
} from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import { getEventEndDate } from "@/lib/calendar-utils";
import { buildWeekSegments, getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -20,7 +20,10 @@ interface CalendarMonthViewProps {
calendars: Calendar[];
onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
firstDayOfWeek?: number;
isMobile?: boolean;
}
export function CalendarMonthView({
@@ -29,7 +32,10 @@ export function CalendarMonthView({
calendars,
onSelectDate,
onSelectEvent,
onHoverEvent,
onHoverLeave,
firstDayOfWeek = 1,
isMobile,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -53,12 +59,7 @@ export function CalendarMonthView({
const map = new Map<string, CalendarEvent[]>();
events.forEach((e) => {
try {
const start = new Date(e.start);
const end = getEventEndDate(e);
const startDay = new Date(start);
startDay.setHours(0, 0, 0, 0);
const endDay = new Date(end);
endDay.setHours(0, 0, 0, 0);
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
@@ -85,6 +86,14 @@ export function CalendarMonthView({
return result;
}, [days]);
const weekSegments = useMemo(() => {
return weeks.map((week) => {
const segments = buildWeekSegments(events, week);
const rowCount = segments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
return { week, segments, rowCount };
});
}, [events, weeks]);
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
@@ -125,22 +134,28 @@ export function CalendarMonthView({
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
<div className="grid grid-cols-7 border-b border-border" role="row">
{dayHeaders.map((d) => (
<div key={d} role="columnheader" className="text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0">
{t(`days.${d}`)}
<div key={d} role="columnheader" className={cn(
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
isMobile && "py-1.5 text-[11px]"
)}>
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
</div>
))}
</div>
<div className="flex-1 flex flex-col overflow-y-auto">
{weeks.map((week, wi) => (
<div key={wi} className="grid grid-cols-7 flex-1 min-h-[100px] border-b border-border last:border-b-0" role="row">
{weekSegments.map(({ week, segments, rowCount }, wi) => (
<div key={wi} className={cn(
"relative flex-1 border-b border-border last:border-b-0",
isMobile ? "min-h-[52px]" : "min-h-[100px]"
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
<div className="grid grid-cols-7 h-full">
{week.map((day) => {
const inMonth = isSameMonth(day, selectedDate);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || [];
const maxVisible = 3;
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return (
@@ -154,16 +169,18 @@ export function CalendarMonthView({
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
className={cn(
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors",
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
!inMonth && "bg-muted/30",
"hover:bg-muted/50",
selected && isMobile && "bg-primary/10",
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10"
)}
>
<div className="flex items-center justify-center mb-0.5">
<span
className={cn(
"inline-flex items-center justify-center w-6 h-6 text-xs rounded-full",
"inline-flex items-center justify-center rounded-full",
isMobile ? "w-7 h-7 text-xs" : "w-6 h-6 text-xs",
today && !selected && "bg-primary text-primary-foreground font-bold",
selected && "bg-primary text-primary-foreground font-bold",
!inMonth && !selected && !today && "text-muted-foreground/50",
@@ -173,29 +190,63 @@ export function CalendarMonthView({
{format(day, "d")}
</span>
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, maxVisible).map((ev) => {
const calId = Object.keys(ev.calendarIds)[0];
return (
<EventCard
key={ev.id}
event={ev}
calendar={calendarMap.get(calId)}
variant="chip"
onClick={(rect) => onSelectEvent(ev, rect)}
draggable
/>
);
})}
{dayEvents.length > maxVisible && (
<div className="text-[10px] text-muted-foreground px-1">
{t("events.more", { count: dayEvents.length - maxVisible })}
{isMobile ? (
dayEvents.length > 0 && (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = Object.keys(ev.calendarIds)[0];
const cal = calendarMap.get(calId);
const evColor = ev.color || cal?.color || "#3b82f6";
return (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: evColor }}
/>
);
})}
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
</div>
)}
</div>
)
) : null}
</div>
);
})}
</div>
{!isMobile && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
{segments.map((segment) => {
const calId = Object.keys(segment.event.calendarIds)[0];
return (
<div
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
top: segment.row * 22,
height: 20,
}}
>
<EventCard
event={segment.event}
calendar={calendarMap.get(calId)}
variant="span"
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
draggable
/>
</div>
);
})}
</div>
)}
</div>
))}
</div>
+163 -18
View File
@@ -1,23 +1,99 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import type { JMAPClient } from "@/lib/jmap/client";
interface CalendarSidebarPanelProps {
calendars: Calendar[];
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
client?: JMAPClient | null;
}
export function CalendarSidebarPanel({
calendars,
selectedCalendarIds,
onToggleVisibility,
onColorChange,
onSubscribe,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
if (calendars.length === 0) return null;
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorPickerId && !contextMenuCalId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenuCalId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setColorPickerId(null);
setContextMenuCalId(null);
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId, contextMenuCalId]);
const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
};
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
setContextMenuCalId(null);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
} catch {
toast.error(tSub('refresh_error'));
} finally {
setRefreshingSubId(null);
}
};
const handleUnsubscribe = async (subId: string) => {
if (!client) return;
setContextMenuCalId(null);
try {
await removeICalSubscription(client, subId);
toast.success(tSub('deleted'));
} catch {
toast.error(tSub('delete_error'));
}
};
if (calendars.length === 0 && !onSubscribe) return null;
return (
<div className="mt-4">
@@ -30,25 +106,94 @@ export function CalendarSidebarPanel({
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded text-sm transition-colors",
"hover:bg-muted"
)}
>
<span
<div key={cal.id} className="relative">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
>
<span
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
{isSubscriptionCalendar(cal.id) && (
<>
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
)}
</>
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/>
</div>
)}
</div>
);
})}
</div>
+193 -27
View File
@@ -1,11 +1,13 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types";
interface CalendarToolbarProps {
selectedDate: Date;
@@ -16,9 +18,13 @@ interface CalendarToolbarProps {
onViewModeChange: (mode: CalendarViewMode) => void;
onCreateEvent: () => void;
onImport?: () => void;
onSubscribe?: () => void;
isMobile?: boolean;
firstDayOfWeek?: number;
onNavigateBack?: () => void;
calendars?: Calendar[];
selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void;
}
export function CalendarToolbar({
@@ -30,20 +36,42 @@ export function CalendarToolbar({
onViewModeChange,
onCreateEvent,
onImport,
onSubscribe,
isMobile,
firstDayOfWeek = 1,
calendars,
selectedCalendarIds,
onToggleVisibility,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const formatter = useFormatter();
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showCalendarDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowCalendarDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showCalendarDropdown]);
const getDateLabel = (): string => {
switch (viewMode) {
case "month":
return formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
case "week": {
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
const we = addDays(ws, 6);
if (isMobile) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}`;
}
const sameMonth = ws.getMonth() === we.getMonth();
if (sameMonth) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
@@ -51,30 +79,141 @@ export function CalendarToolbar({
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
}
case "day":
return formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return isMobile
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
case "agenda":
return formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
}
};
return (
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap">
<div className="flex items-center gap-1">
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium min-w-[140px] text-center">
{getDateLabel()}
</span>
<button onClick={onNext} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
</div>
const [showImportDropdown, setShowImportDropdown] = useState(false);
const importDropdownRef = useRef<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(null);
<Button variant="outline" size="sm" onClick={onToday}>
useEffect(() => {
if (!showImportDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) {
setShowImportDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]);
useEffect(() => {
if (!showViewDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) {
setShowViewDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showViewDropdown]);
return (
<div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}>
{isMobile && (
<div className="flex items-center gap-0.5">
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className={cn("text-sm font-medium text-center min-w-[80px]")}>
{getDateLabel()}
</span>
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
{t("views.today")}
</Button>
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowCalendarDropdown((v) => !v)}
aria-label={t("my_calendars")}
className="touch-manipulation"
>
<CalendarDays className="w-4 h-4" />
</Button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
)}
{isMobile && (
<div className="relative" ref={viewDropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowViewDropdown((v) => !v)}
className="touch-manipulation capitalize text-xs"
>
{t(`views.${viewMode}`)}
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
</Button>
{showViewDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]">
{views.map((v) => (
<button
key={v}
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
className={cn(
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
)}
</div>
)}
<div className="flex-1" />
{!isMobile && (
@@ -97,17 +236,44 @@ export function CalendarToolbar({
</div>
)}
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1" />
{!isMobile && t("import.title")}
</Button>
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showImportDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
{onImport && (
<button
onClick={() => { onImport(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Upload className="w-4 h-4" />
{t("import.title")}
</button>
)}
{onSubscribe && (
<button
onClick={() => { onSubscribe(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Globe className="w-4 h-4" />
{t("subscription.title")}
</button>
)}
</div>
)}
</div>
)}
<Button size="sm" onClick={onCreateEvent}>
<Plus className="w-4 h-4 mr-1" />
{!isMobile && t("events.create")}
</Button>
{!isMobile && (
<Button size="sm" onClick={onCreateEvent}>
<Plus className="w-4 h-4 mr-1" />
{t("events.create")}
</Button>
)}
</div>
);
}
+77 -47
View File
@@ -8,7 +8,7 @@ import {
import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
@@ -18,9 +18,12 @@ interface CalendarWeekViewProps {
calendars: Calendar[];
onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
}
const HOUR_HEIGHT = 60;
@@ -32,9 +35,12 @@ export function CalendarWeekView({
calendars,
onSelectDate,
onSelectEvent,
onHoverEvent,
onHoverLeave,
onCreateAtTime,
firstDayOfWeek = 1,
timeFormat = "24h",
isMobile,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -42,9 +48,13 @@ export function CalendarWeekView({
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekDays = useMemo(() => {
if (isMobile) {
// Show 3-day window centered on selected date
return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1));
}
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStart]);
}, [selectedDate, weekStart, isMobile]);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
@@ -52,25 +62,17 @@ export function CalendarWeekView({
return map;
}, [calendars]);
const { timedEvents, allDayEvents } = useMemo(() => {
const timedEvents = useMemo(() => {
const timed: Map<string, CalendarEvent[]> = new Map();
const allDay: Map<string, CalendarEvent[]> = new Map();
events.forEach((ev) => {
try {
const start = new Date(ev.start);
const end = getEventEndDate(ev);
const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
const endDay = new Date(end); endDay.setHours(0, 0, 0, 0);
const { startDay, endDay } = getEventDayBounds(ev);
const cursor = new Date(startDay);
while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd");
if (ev.showWithoutTime) {
const arr = allDay.get(key) || [];
arr.push(ev);
allDay.set(key, arr);
} else {
if (!ev.showWithoutTime) {
const arr = timed.get(key) || [];
arr.push(ev);
timed.set(key, arr);
@@ -79,15 +81,21 @@ export function CalendarWeekView({
}
} catch { /* skip invalid dates */ }
});
return { timedEvents: timed, allDayEvents: allDay };
return timed;
}, [events]);
const allDaySegments = useMemo(() => buildWeekSegments(
events.filter((event) => event.showWithoutTime),
weekDays,
), [events, weekDays]);
const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
}, [allDaySegments]);
const hasAllDay = useMemo(() => {
return weekDays.some(day => {
const key = format(day, "yyyy-MM-dd");
return (allDayEvents.get(key) || []).length > 0;
});
}, [weekDays, allDayEvents]);
return allDaySegments.length > 0;
}, [allDaySegments]);
useEffect(() => {
if (scrollRef.current) {
@@ -132,41 +140,61 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const colCount = isMobile ? 3 : 7;
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
<div className="flex border-b border-border">
<div className="w-14 flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right">
<div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
>
{t("events.all_day")}
</div>
<div className="flex-1 grid grid-cols-7 gap-px bg-border">
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayAllDay = allDayEvents.get(key) || [];
return (
<div key={key} className="bg-background p-0.5 min-h-[28px]">
{dayAllDay.map((ev) => {
const calId = Object.keys(ev.calendarIds)[0];
return (
<EventCard
key={ev.id}
event={ev}
calendar={calendarMap.get(calId)}
variant="chip"
onClick={(rect) => onSelectEvent(ev, rect)}
/>
);
})}
</div>
);
})}
<div
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
>
{weekDays.map((day) => (
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
))}
<div className="absolute inset-0 pointer-events-none">
{allDaySegments.map((segment) => {
const calId = Object.keys(segment.event.calendarIds)[0];
return (
<div
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(segment.startIndex / colCount) * 100}% + 1px)`,
width: `calc(${(segment.span / colCount) * 100}% - 2px)`,
top: segment.row * 24 + 2,
height: 20,
}}
>
<EventCard
event={segment.event}
calendar={calendarMap.get(calId)}
variant="span"
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
/>
</div>
);
})}
</div>
</div>
</div>
)}
<div className="flex border-b border-border" role="row">
<div className="w-14 flex-shrink-0" />
<div className="flex-1 grid grid-cols-7 border-l border-border">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
@@ -178,7 +206,7 @@ export function CalendarWeekView({
role="columnheader"
aria-label={fullLabel}
className={cn(
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors",
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
"hover:bg-muted/50",
todayCol && "font-bold",
)}
@@ -201,7 +229,7 @@ export function CalendarWeekView({
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="w-14 flex-shrink-0">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
{HOURS.map((h) => (
<div
key={h}
@@ -209,7 +237,7 @@ export function CalendarWeekView({
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className="absolute top-0 right-2 -translate-y-1/2 text-[10px] leading-none">
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[9px]" : "text-[10px]")}>
{formatHour(h)}
</span>
)}
@@ -217,7 +245,7 @@ export function CalendarWeekView({
))}
</div>
<div className="flex-1 grid grid-cols-7 border-l border-border relative">
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
@@ -272,6 +300,8 @@ export function CalendarWeekView({
calendar={calendarMap.get(calId)}
variant="block"
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
draggable
/>
<div
+49 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState, type DragEvent } from "react";
import { useCallback, useState, type CSSProperties, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -11,10 +11,16 @@ import { getParticipantCount } from "@/lib/calendar-participants";
interface EventCardProps {
event: CalendarEvent;
calendar?: Calendar;
variant: "chip" | "block";
variant: "chip" | "block" | "span";
onClick?: (anchorRect: DOMRect) => void;
onMouseEnter?: (anchorRect: DOMRect) => void;
onMouseLeave?: () => void;
isSelected?: boolean;
draggable?: boolean;
continuesBefore?: boolean;
continuesAfter?: boolean;
className?: string;
style?: CSSProperties;
}
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
@@ -28,7 +34,8 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
}
function parseDuration(duration: string): number {
function parseDuration(duration: string | undefined): number {
if (!duration) return 0;
let totalMinutes = 0;
const weekMatch = duration.match(/(\d+)W/);
const hourMatch = duration.match(/(\d+)H/);
@@ -56,7 +63,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, isSelected, draggable: isDraggable }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -101,6 +108,8 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -108,9 +117,10 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
"min-h-[44px] sm:min-h-0",
"hover:opacity-80 transition-opacity",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50"
isBeingDragged && "opacity-50",
className
)}
style={{ backgroundColor: `${color}20`, color }}
style={{ backgroundColor: `${color}20`, color, ...style }}
>
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
@@ -121,9 +131,39 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
);
}
if (variant === "span") {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesBefore && "rounded-l-sm",
continuesAfter && "rounded-r-sm",
continuesBefore && "-ml-0.5",
continuesAfter && "pr-2",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
className
)}
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
>
<div className="flex items-center gap-1 min-w-0">
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
</div>
</button>
);
}
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
@@ -131,9 +171,10 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50"
isBeingDragged && "opacity-50",
className
)}
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
>
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
{!event.showWithoutTime && (
+25 -8
View File
@@ -9,6 +9,7 @@ import {
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import {
@@ -28,8 +29,11 @@ interface EventDetailPopoverProps {
onClose: () => void;
onSaveNote: (note: string) => void;
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
currentUserEmails?: string[];
timeFormat?: "12h" | "24h";
isMobile?: boolean;
}
const POPOVER_WIDTH = 360;
@@ -117,8 +121,11 @@ export function EventDetailPopover({
onClose,
onSaveNote,
onRsvp,
onMouseEnter,
onMouseLeave,
currentUserEmails = [],
timeFormat = "24h",
isMobile,
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const popoverRef = useRef<HTMLDivElement>(null);
@@ -200,17 +207,14 @@ export function EventDetailPopover({
onClose();
}
};
const handleScroll = () => onClose();
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("scroll", handleScroll, true);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
@@ -249,8 +253,18 @@ export function EventDetailPopover({
ref={popoverRef}
role="dialog"
aria-label={event.title || t("events.no_title")}
className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out"
style={{
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={cn(
"fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out",
isMobile
? "inset-0 rounded-none flex flex-col"
: "rounded-lg"
)}
style={isMobile ? {
opacity: 1,
transform: "none",
} : {
width: POPOVER_WIDTH,
maxHeight: MAX_HEIGHT,
top: position?.top ?? -9999,
@@ -293,15 +307,18 @@ export function EventDetailPopover({
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors flex-shrink-0 mt-0.5"
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground"
aria-label={t("form.cancel")}
>
<X className="w-4 h-4 text-muted-foreground" />
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="px-4 py-2 space-y-2.5 overflow-y-auto" style={{ maxHeight: MAX_HEIGHT - 140 }}>
<div className={cn(
"px-4 py-2 space-y-2.5 overflow-y-auto",
isMobile ? "flex-1" : ""
)} style={isMobile ? undefined : { maxHeight: MAX_HEIGHT - 140 }}>
{/* Date & Time */}
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
+250 -31
View File
@@ -4,10 +4,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy } from "lucide-react";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "lucide-react";
import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration } from "./event-card";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate } from "@/lib/calendar-utils";
import { ParticipantInput } from "./participant-input";
import {
isOrganizer,
@@ -29,6 +30,7 @@ interface EventModalProps {
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void;
currentUserEmails?: string[];
isMobile?: boolean;
}
function formatDateInput(d: Date): string {
@@ -57,6 +59,41 @@ function buildDuration(startDate: Date, endDate: Date): string {
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
function formatDurationDisplay(minutes: number): string {
if (minutes < 60) return `${minutes}min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (m === 0) return `${h}h`;
return `${h}h${m}min`;
}
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT?(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
}
export function EventModal({
event,
calendars,
@@ -68,9 +105,11 @@ export function EventModal({
onRsvp,
onClose,
currentUserEmails = [],
isMobile = false,
}: EventModalProps) {
const t = useTranslations("calendar");
const isEdit = !!event;
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
const userIsOrganizer = useMemo(() => {
if (!event) return true;
@@ -120,6 +159,9 @@ export function EventModal({
const getInitialEnd = (): Date => {
if (event?.start) {
if (event.showWithoutTime) {
return getEventDisplayEndDate(event);
}
const s = parseISO(event.start);
const dur = parseDuration(event.duration);
return new Date(s.getTime() + dur * 60000);
@@ -194,21 +236,25 @@ export function EventModal({
const startStr = allDay
? `${startDate}T00:00:00`
: `${startDate}T${startTime}:00`;
const endStr = allDay
? `${endDate}T23:59:59`
: `${endDate}T${endTime}:00`;
const start = new Date(startStr);
let end = new Date(endStr);
let duration: string;
if (end <= start) {
end = new Date(start.getTime() + 3600000);
if (allDay) {
let inclusiveEnd = new Date(`${endDate}T00:00:00`);
if (inclusiveEnd < start) {
inclusiveEnd = new Date(start);
}
duration = buildAllDayDuration(start, inclusiveEnd);
} else {
const endStr = `${endDate}T${endTime}:00`;
let end = new Date(endStr);
if (end <= start) {
end = new Date(start.getTime() + 3600000);
}
duration = buildDuration(start, end);
}
const duration = allDay
? `P${Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))}D`
: buildDuration(start, end);
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const data: Partial<CalendarEvent> = {
@@ -216,7 +262,7 @@ export function EventModal({
description: description.trim(),
start: startStr,
duration,
timeZone,
timeZone: allDay ? null : timeZone,
showWithoutTime: allDay,
calendarIds: { [calendarId]: true },
status: "confirmed",
@@ -330,7 +376,13 @@ export function EventModal({
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Escape") {
if (mode === "edit" && isEdit) {
setMode("view");
} else {
onClose();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
if (!isAttendeeMode) handleSave();
@@ -338,7 +390,7 @@ export function EventModal({
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, handleSave, isAttendeeMode]);
}, [onClose, handleSave, isAttendeeMode, mode, isEdit]);
useEffect(() => {
const modal = modalRef.current;
@@ -374,17 +426,16 @@ export function EventModal({
const participants = getParticipantList(event);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={event.title || t("events.no_title")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors" aria-label={t("form.cancel")}>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 space-y-3">
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
@@ -431,8 +482,9 @@ export function EventModal({
</div>
)}
</div>
</div>
<div className="px-5 py-4 border-t border-border">
<div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
@@ -472,25 +524,192 @@ export function EventModal({
</div>
</div>
</div>
</div>
);
}
// View mode: read-only display of event details with Edit button
if (mode === "view" && event) {
const startD = parseISO(event.start);
const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000);
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
const recurrenceLabel = getRecurrenceLabel(event, t);
const alertLabel = getAlertLabel(event, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = getEventColor(event, eventCalendar);
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
{/* Color accent bar */}
<div className="h-1 w-full flex-shrink-0" style={{ backgroundColor: color }} />
{/* Header */}
<div className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
</div>
{eventCalendar && (
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">{eventCalendar.name}</p>
)}
</div>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-3">
{/* Date & Time */}
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{format(startD, "EEE, MMM d, yyyy")}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{format(startD, "HH:mm")} {format(endD, "HH:mm")}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div>
)}
</div>
</div>
{/* Location */}
{locationName && (
<div className="flex items-start gap-2.5">
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{/^https?:\/\//i.test(locationName) ? (
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
</a>
) : (
<span className="text-sm text-foreground">{locationName}</span>
)}
</div>
)}
{/* Virtual Location */}
{virtualLoc && (
<div className="flex items-start gap-2.5">
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
</a>
</div>
)}
{/* Participants */}
{viewParticipants.length > 0 && (
<div className="flex items-start gap-2.5">
<Users className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm min-w-0">
<span className="text-muted-foreground">
{t("participants.count", { count: viewParticipants.length })}
</span>
<div className="mt-1 space-y-0.5">
{viewParticipants.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-foreground">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ml-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
</div>
))}
</div>
</div>
</div>
)}
{/* Recurrence */}
{recurrenceLabel && (
<div className="flex items-start gap-2.5">
<Repeat className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground">{recurrenceLabel}</span>
</div>
)}
{/* Reminder */}
{alertLabel && (
<div className="flex items-start gap-2.5">
<Bell className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground">{alertLabel}</span>
</div>
)}
{/* Description */}
{event.description && (
<div className="flex items-start gap-2.5">
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-muted-foreground whitespace-pre-line">{event.description}</p>
</div>
)}
</div>
</div>
{/* Action Bar */}
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between">
<div className="flex items-center gap-1">
{onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600 dark:text-red-400">{t("form.delete_confirm")}</span>
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700">
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400">
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")}
</Button>
)
)}
{onDuplicate && !showDeleteConfirm && (
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
<Copy className="w-4 h-4 mr-1" />
{t("events.duplicate")}
</Button>
)}
</div>
{!showDeleteConfirm && (
<Button onClick={() => setMode("edit")}>
<Pencil className="w-4 h-4 mr-1" />
{t("events.edit")}
</Button>
)}
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={isEdit ? t("events.edit") : t("events.create")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors" aria-label={t("form.cancel")}>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 space-y-4">
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
<Input
@@ -665,8 +884,9 @@ export function EventModal({
</div>
)}
</div>
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1">
{isEdit && onDelete && (
showDeleteConfirm ? (
@@ -719,7 +939,7 @@ export function EventModal({
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={!title.trim()}>
@@ -727,7 +947,6 @@ export function EventModal({
</Button>
</div>
</div>
</div>
</div>
);
}
+141 -30
View File
@@ -3,7 +3,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw } from "lucide-react";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { JMAPClient } from "@/lib/jmap/client";
@@ -20,6 +20,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
@@ -38,6 +39,9 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [importMode, setImportMode] = useState<ImportMode>("file");
const [urlInput, setUrlInput] = useState("");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -103,6 +107,57 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
if (file) handleFile(file);
}, [handleFile]);
const handleUrlFetch = useCallback(async () => {
const trimmed = urlInput.trim();
if (!trimmed) return;
try {
new URL(trimmed);
} catch {
setError(t("invalid_url"));
return;
}
setError(null);
setIsFetchingUrl(true);
setIsParsing(true);
try {
const response = await fetch("/api/fetch-ical", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: trimmed }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || t("url_fetch_failed"));
}
const blob = await response.blob();
const file = new File([blob], "calendar.ics", { type: "text/calendar" });
const uploaded = await client.uploadBlob(file);
const accountId = client.getCalendarsAccountId();
const events = await client.parseCalendarEvents(accountId, uploaded.blobId);
if (events.length === 0) {
setError(t("no_events"));
setIsFetchingUrl(false);
setIsParsing(false);
return;
}
setParsedEvents(events);
setSelectedIndices(new Set(events.map((_, i) => i)));
setStep("preview");
} catch (err) {
setError(err instanceof Error ? err.message : t("url_fetch_failed"));
} finally {
setIsFetchingUrl(false);
setIsParsing(false);
}
}, [urlInput, client, t]);
const toggleEvent = useCallback((index: number) => {
setSelectedIndices((prev) => {
const next = new Set(prev);
@@ -181,50 +236,106 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("title")}</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 space-y-4">
<div className="px-6 py-4 space-y-4">
{step === "select" && !isParsing && (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">{t("select_file")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical"
onChange={handleFileChange}
className="hidden"
/>
</div>
<>
<div className="flex border-b border-border mb-4">
<button
onClick={() => { setImportMode("file"); setError(null); }}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
importMode === "file"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Upload className="w-4 h-4" />
{t("tab_file")}
</button>
<button
onClick={() => { setImportMode("url"); setError(null); }}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
importMode === "url"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="w-4 h-4" />
{t("tab_url")}
</button>
</div>
{importMode === "file" && (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">{t("select_file")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical"
onChange={handleFileChange}
className="hidden"
/>
</div>
)}
{importMode === "url" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{t("url_description")}</p>
<div className="flex gap-2">
<input
type="url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder={t("url_placeholder")}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }}
/>
<Button
onClick={handleUrlFetch}
disabled={!urlInput.trim() || isFetchingUrl}
>
{isFetchingUrl ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t("fetch")
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("url_hint")}</p>
</div>
)}
</>
)}
{isParsing && (
@@ -318,7 +429,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
</div>
{step !== "importing" && (
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border">
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{tForm("cancel")}
</Button>
@@ -0,0 +1,205 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react";
import type { JMAPClient } from "@/lib/jmap/client";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: JMAPClient;
onClose: () => void;
}
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(60);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const isValid = url.trim().length > 0 && name.trim().length > 0;
const handleSubmit = useCallback(async () => {
let trimmedUrl = url.trim();
if (!trimmedUrl || !name.trim()) return;
// Convert webcal:// to https://
if (trimmedUrl.startsWith("webcal://")) {
trimmedUrl = trimmedUrl.replace(/^webcal:\/\//, "https://");
}
try {
new URL(trimmedUrl);
} catch {
setError(t("invalid_url"));
return;
}
setError(null);
setIsSubmitting(true);
try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
}
} catch {
setError(t("error"));
} finally {
setIsSubmitting(false);
}
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<p className="text-sm text-muted-foreground">{t("description")}</p>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("url_label")}
</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("url_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color_label")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("refresh_interval")}
</label>
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
>
<option value={15}>{t("interval_15")}</option>
<option value={30}>{t("interval_30")}</option>
<option value={60}>{t("interval_60")}</option>
<option value={360}>{t("interval_360")}</option>
<option value={1440}>{t("interval_1440")}</option>
</select>
</div>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
{error}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("subscribing")}
</>
) : (
t("subscribe")
)}
</Button>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -138,9 +138,9 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
{participants.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{participants.map((p) => (
{participants.map((p, i) => (
<span
key={p.email}
key={`${p.email}-${i}`}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
>
<span className="truncate">{p.name || p.email}</span>
@@ -67,7 +67,8 @@ describe('ContactForm', () => {
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[0], { target: { value: 'Jane' } });
const givenNameInput = screen.getByPlaceholderText('given_name');
fireEvent.change(givenNameInput, { target: { value: 'Jane' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
@@ -23,33 +23,62 @@ const _emptyContact: ContactCard = {
};
describe('ContactListItem', () => {
const baseProps = {
isSelected: false,
isChecked: false,
hasSelection: false,
density: 'regular' as const,
onClick: vi.fn(),
onCheckboxClick: vi.fn(),
};
it('renders contact name and email', () => {
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
render(<ContactListItem contact={contact} {...baseProps} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
it('renders organization', () => {
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
it('renders organization in comfortable density', () => {
render(<ContactListItem contact={contact} {...baseProps} density="comfortable" />);
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
});
it('hides organization in regular density', () => {
render(<ContactListItem contact={contact} {...baseProps} density="regular" />);
expect(screen.queryByText('Acme Corp')).not.toBeInTheDocument();
});
it('applies selected styling', () => {
const { container } = render(<ContactListItem contact={contact} isSelected={true} onClick={vi.fn()} />);
const button = container.querySelector('button');
expect(button?.className).toContain('bg-accent');
const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />);
const div = container.firstElementChild;
expect(div?.className).toContain('bg-blue-200');
});
it('shows email as display name when no name exists', () => {
render(<ContactListItem contact={noNameContact} isSelected={false} onClick={vi.fn()} />);
render(<ContactListItem contact={noNameContact} {...baseProps} />);
const matches = screen.getAllByText('nobody@example.com');
expect(matches.length).toBeGreaterThanOrEqual(1);
});
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<ContactListItem contact={contact} isSelected={false} onClick={onClick} />);
render(<ContactListItem contact={contact} {...baseProps} onClick={onClick} />);
fireEvent.click(screen.getByText('Alice Smith'));
expect(onClick).toHaveBeenCalledOnce();
});
it('does not show checkbox when hasSelection is false', () => {
const { container } = render(<ContactListItem contact={contact} {...baseProps} hasSelection={false} />);
expect(container.querySelector('button')).not.toBeInTheDocument();
});
it('shows checkbox when hasSelection is true', () => {
const { container } = render(<ContactListItem contact={contact} {...baseProps} hasSelection={true} />);
expect(container.querySelector('button')).toBeInTheDocument();
});
it('hides avatar in extra-compact density', () => {
const { container } = render(<ContactListItem contact={contact} {...baseProps} density="extra-compact" />);
expect(container.querySelector('[data-testid="avatar"]') || container.querySelector('.rounded-full')).toBeNull();
});
});
@@ -36,15 +36,15 @@ const defaultProps = {
onSearchChange: vi.fn(),
onSelectContact: vi.fn(),
onCreateNew: vi.fn(),
supportsSync: true,
categoryLabel: 'All Contacts',
selectedContactIds: new Set<string>(),
onToggleSelection: vi.fn(),
onSelectRangeContacts: vi.fn(),
onSelectAll: vi.fn(),
onClearSelection: vi.fn(),
onBulkDelete: vi.fn(),
onBulkAddToGroup: vi.fn(),
onBulkExport: vi.fn(),
groups: [],
};
describe('ContactList', () => {
@@ -70,32 +70,14 @@ describe('ContactList', () => {
expect(screen.getByText('empty_search')).toBeInTheDocument();
});
it('shows local mode banner when supportsSync is false', () => {
render(<ContactList {...defaultProps} supportsSync={false} />);
expect(screen.getByText('local_mode')).toBeInTheDocument();
});
it('hides local mode banner when supportsSync is true', () => {
render(<ContactList {...defaultProps} supportsSync={true} />);
expect(screen.queryByText('local_mode')).not.toBeInTheDocument();
});
it('calls onCreateNew when create button is clicked', () => {
const onCreateNew = vi.fn();
render(<ContactList {...defaultProps} onCreateNew={onCreateNew} />);
fireEvent.click(screen.getByText('create_new'));
expect(onCreateNew).toHaveBeenCalledOnce();
});
it('shows bulk action bar when contacts are selected', () => {
render(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} />);
expect(screen.getByText('bulk.delete')).toBeInTheDocument();
expect(screen.getByText('bulk.export')).toBeInTheDocument();
});
it('excludes groups from the list', () => {
render(<ContactList {...defaultProps} contacts={[alice, bob, group]} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.queryByText('Team')).not.toBeInTheDocument();
it('shows category label with count', () => {
render(<ContactList {...defaultProps} />);
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
});
});
+364 -110
View File
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send } from "lucide-react";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -13,10 +13,48 @@ interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
onDelete: () => void;
isMobile?: boolean;
className?: string;
}
export function ContactDetail({ contact, onEdit, onDelete, className }: ContactDetailProps) {
function formatPhoneFeatures(features?: Record<string, boolean>): string {
if (!features) return "";
return Object.keys(features).filter(k => features[k]).join(", ");
}
function formatDate(dateInput: string | Record<string, unknown>): string {
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
if (typeof dateInput === 'object' && dateInput !== null) {
const year = dateInput.year as number | undefined;
const month = dateInput.month as number | undefined;
const day = dateInput.day as number | undefined;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
if (day) parts.push(String(day));
if (year) parts.push(String(year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) {
// Partial date without year
const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1];
}
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
}
} catch { /* fallback */ }
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
if (!contact) {
@@ -35,149 +73,345 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
const notes = contact.notes ? Object.values(contact.notes) : [];
const titles = contact.titles ? Object.values(contact.titles) : [];
const jobTitles = titles.filter(t => t.kind !== "role");
const roles = titles.filter(t => t.kind === "role");
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
const cryptoKeys = contact.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
const nicknames = contact.nicknames ? Object.values(contact.nicknames) : [];
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border">
<div className="flex items-start justify-between">
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<Avatar name={name} email={email} size="lg" />
<div>
<h2 className="text-xl font-semibold">{name || "—"}</h2>
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
{hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)}
{titleLine && (
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
)}
{orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground">{orgs[0].name}</p>
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950">
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="px-6 py-4 space-y-6">
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")}>
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
{e.address}
</a>
{e.contexts && (
<ContextBadge contexts={e.contexts} />
)}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<a
href={`mailto:${e.address}`}
className="p-1 rounded hover:bg-muted transition-colors"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
<div className="px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{/* Contact info */}
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact">
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
{e.address}
</a>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(e.address);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
}}
className="p-1 rounded hover:bg-muted transition-colors"
title={t("detail.copy_email")}
aria-label={t("detail.copy_email")}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
{e.contexts && <ContextBadge contexts={e.contexts} />}
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
<div className={cn(
"flex items-center gap-0.5 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</div>
</div>
</div>
))}
</Section>
)}
))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")}>
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
{p.contexts && (
<ContextBadge contexts={p.contexts} />
)}
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(p.number);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
}}
className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title={t("detail.copy_phone")}
aria-label={t("detail.copy_phone")}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")} category="contact">
{phones.map((p, i) => {
const featureStr = formatPhoneFeatures(p.features);
return (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
{p.contexts && <ContextBadge contexts={p.contexts} />}
{featureStr && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
)}
<CopyButton
value={p.number}
label={t("detail.copy_phone")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
);
})}
</Section>
)}
{orgs.length > 0 && (
<Section icon={Building} title={t("detail.organizations")}>
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> {o.units.map(u => u.name).join(", ")}</span>
{(roles.length > 0 || jobTitles.length > 1) && (
<Section icon={Briefcase} title={t("detail.titles")} category="work">
{titles.map((tl, i) => (
<div key={i} className="text-sm flex items-center gap-2">
<span>{tl.name}</span>
{tl.kind && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
)}
</div>
))}
</Section>
)}
{orgs.length > 0 && (
<Section icon={Building} title={t("detail.organizations")} category="work">
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
))}
</Section>
)}
{/* Addresses span full width */}
{addresses.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={MapPin} title={t("detail.addresses")} category="location">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{addresses.map((a, i) => (
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
<div>
{a.fullAddress
? a.fullAddress
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
{a.contexts && <ContextBadge contexts={a.contexts} />}
</div>
{a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
)}
</div>
))}
</div>
</Section>
</div>
)}
{onlineServices.length > 0 && (
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group">
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
{svc.service && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
)}
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
<CopyButton
value={svc.user || svc.uri}
label={t("detail.copy_url")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
))}
</Section>
)}
{anniversaries.length > 0 && (
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{formatDate(ann.date)}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{t(`detail.anniversary_${ann.kind}`)}
</span>
</div>
))}
</Section>
)}
{personalInfo.length > 0 && (
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
{personalInfo.map((pi, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{pi.value}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
{pi.level && (
<span className="text-xs text-muted-foreground">({pi.level})</span>
)}
</div>
))}
</Section>
)}
{contact.gender && (contact.gender.sex || contact.gender.identity) && (
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
<div className="text-sm">
{contact.gender.sex && <span>{t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}</span>}
{contact.gender.identity && (
<span className="text-muted-foreground">{contact.gender.sex ? " — " : ""}{contact.gender.identity}</span>
)}
</div>
))}
</Section>
)}
</Section>
)}
{addresses.length > 0 && (
<Section icon={MapPin} title={t("detail.addresses")}>
{addresses.map((a, i) => (
<div key={i} className="text-sm">
{[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
{a.contexts && (
<ContextBadge contexts={a.contexts} />
)}
{preferredLanguages.length > 0 && (
<Section icon={Languages} title={t("detail.languages")} category="personal">
{preferredLanguages.map((lang, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{lang.language}</span>
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
</div>
))}
</Section>
)}
{keywords.length > 0 && (
<Section icon={Tag} title={t("detail.categories")} category="digital">
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw}
</span>
))}
</div>
))}
</Section>
)}
</Section>
)}
{notes.length > 0 && (
<Section icon={StickyNote} title={t("detail.notes")}>
{notes.map((n, i) => (
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
))}
</Section>
)}
{relatedTo.length > 0 && (
<Section icon={Users} title={t("detail.related_contacts")} category="personal">
{relatedTo.map(([uri, rel], i) => {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
return (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{uri}</span>
{relType && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
)}
</div>
);
})}
</Section>
)}
{cryptoKeys.length > 0 && (
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
{cryptoKeys.map((key, i) => (
<div key={i} className="text-sm break-all">
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)}
</div>
))}
</Section>
)}
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
{contact.calendarUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
</div>
)}
{contact.schedulingUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
</div>
)}
{contact.freeBusyUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
</div>
)}
</Section>
)}
{/* Notes span full width */}
{notes.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
{notes.map((n, i) => (
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
))}
</Section>
</div>
)}
{/* Timestamps span full width */}
{(contact.created || contact.updated) && (
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
</div>
)}
</div>
</div>
</div>
);
}
function Section({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const categoryStyles: Record<SectionCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
return (
<div>
<div className="flex items-center gap-2 mb-2">
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1 pl-6">{children}</div>
<div className="space-y-1.5 pl-6">{children}</div>
</div>
);
}
@@ -187,8 +421,28 @@ function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
if (labels.length === 0) return null;
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
{labels.join(", ")}
</span>
);
}
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
toast.success(successMsg);
} catch {
toast.error(failMsg);
}
}}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
title={label}
aria-label={label}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
);
}
+631 -175
View File
@@ -2,11 +2,11 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { X, Plus } from "lucide-react";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo } from "@/lib/jmap/types";
interface EmailEntry {
address: string;
@@ -16,6 +16,33 @@ interface EmailEntry {
interface PhoneEntry {
number: string;
context: "work" | "private" | "";
feature: "voice" | "cell" | "fax" | "pager" | "video" | "text" | "";
}
interface OnlineServiceEntry {
uri: string;
service: string;
label: string;
}
interface AnniversaryEntry {
date: string;
kind: "birth" | "death" | "wedding" | "other";
}
interface PersonalInfoEntry {
value: string;
kind: "expertise" | "hobby" | "interest" | "other";
level: "high" | "medium" | "low" | "";
}
interface AddressEntry {
street: string;
locality: string;
region: string;
postcode: string;
country: string;
context: "work" | "private" | "";
}
interface ContactFormProps {
@@ -24,15 +51,92 @@ interface ContactFormProps {
onCancel: () => void;
}
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const formCategoryStyles: Record<FormCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
category?: FormCategory;
}) {
const [open, setOpen] = useState(defaultOpen || !collapsible);
return (
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
<button
type="button"
className={cn(
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
collapsible && "hover:text-muted-foreground cursor-pointer",
!collapsible && "cursor-default"
)}
onClick={() => collapsible && setOpen(!open)}
tabIndex={collapsible ? 0 : -1}
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-left">{title}</span>
{collapsible && (
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
{open && (
<div className="space-y-3 pt-3 pb-1">
{children}
</div>
)}
</div>
);
}
function Select({ value, onChange, children, className }: {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
children: React.ReactNode;
className?: string;
}) {
return (
<select
value={value}
onChange={onChange}
className={cn(
"text-sm bg-transparent border border-input rounded-md px-2.5 py-2 text-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1",
"hover:border-muted-foreground/50 transition-colors",
className
)}
>
{children}
</select>
);
}
export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
const givenInit = contact?.name?.components?.find(c => c.kind === "given")?.value || "";
const surnameInit = contact?.name?.components?.find(c => c.kind === "surname")?.value || "";
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
const [givenName, setGivenName] = useState(givenInit);
const [surname, setSurname] = useState(surnameInit);
const [prefix, setPrefix] = useState(findComponent("prefix"));
const [givenName, setGivenName] = useState(findComponent("given"));
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
const [surname, setSurname] = useState(findComponent("surname"));
const [suffix, setSuffix] = useState(findComponent("suffix"));
const [nickname, setNickname] = useState(
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
);
const [emails, setEmails] = useState<EmailEntry[]>(() => {
if (contact?.emails) {
@@ -49,6 +153,7 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
return Object.values(contact.phones).map(p => ({
number: p.number,
context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "",
feature: p.features?.cell ? "cell" : p.features?.fax ? "fax" : p.features?.pager ? "pager" : p.features?.video ? "video" : p.features?.text ? "text" : p.features?.voice ? "voice" : "",
}));
}
return [];
@@ -57,11 +162,85 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
const [organization, setOrganization] = useState(
contact?.organizations ? Object.values(contact.organizations)[0]?.name || "" : ""
);
const [department, setDepartment] = useState(
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
);
const [jobTitle, setJobTitle] = useState(() => {
if (contact?.titles) {
const t = Object.values(contact.titles).find(t => t.kind !== "role");
return t?.name || "";
}
return "";
});
const [role, setRole] = useState(() => {
if (contact?.titles) {
const r = Object.values(contact.titles).find(t => t.kind === "role");
return r?.name || "";
}
return "";
});
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
if (contact?.addresses) {
return Object.values(contact.addresses).map(a => ({
street: a.street || "",
locality: a.locality || "",
region: a.region || "",
postcode: a.postcode || "",
country: a.country || "",
context: a.contexts?.work ? "work" : a.contexts?.private ? "private" : "",
}));
}
return [];
});
const [onlineServices, setOnlineServices] = useState<OnlineServiceEntry[]>(() => {
if (contact?.onlineServices) {
return Object.values(contact.onlineServices).map(s => ({
uri: s.uri,
service: s.service || "",
label: s.label || "",
}));
}
return [];
});
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
if (contact?.anniversaries) {
return Object.values(contact.anniversaries).map(a => ({
date: a.date,
kind: a.kind,
}));
}
return [];
});
const [personalInfoEntries, setPersonalInfoEntries] = useState<PersonalInfoEntry[]>(() => {
if (contact?.personalInfo) {
return Object.values(contact.personalInfo).map(p => ({
value: p.value,
kind: p.kind,
level: p.level || "",
}));
}
return [];
});
const [keywordsStr, setKeywordsStr] = useState(
contact?.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]).join(", ") : ""
);
const [note, setNote] = useState(
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
);
const [genderSex, setGenderSex] = useState(contact?.gender?.sex || "");
const [genderIdentity, setGenderIdentity] = useState(contact?.gender?.identity || "");
const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || "");
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || "");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
@@ -110,33 +289,94 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
});
const validPhones = phones.filter(p => p.number.trim());
const phonesMap: Record<string, { number: string; contexts?: Record<string, boolean> }> = {};
const phonesMap: Record<string, { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> }> = {};
validPhones.forEach((entry, i) => {
const obj: { number: string; contexts?: Record<string, boolean> } = { number: entry.number.trim() };
const obj: { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> } = { number: entry.number.trim() };
if (entry.context) {
obj.contexts = { [entry.context]: true };
}
if (entry.feature) {
obj.features = { [entry.feature]: true };
}
phonesMap[`p${i}`] = obj;
});
const nameComponents = [];
if (givenName.trim()) {
nameComponents.push({ kind: "given" as const, value: givenName.trim() });
}
if (surname.trim()) {
nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
if (role.trim()) titlesMap["t1"] = { name: role.trim(), kind: "role" };
const orgUnits = department.trim() ? [{ name: department.trim() }] : undefined;
const addressesMap: Record<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
const obj: Record<string, unknown> = {};
if (a.street.trim()) obj.street = a.street.trim();
if (a.locality.trim()) obj.locality = a.locality.trim();
if (a.region.trim()) obj.region = a.region.trim();
if (a.postcode.trim()) obj.postcode = a.postcode.trim();
if (a.country.trim()) obj.country = a.country.trim();
if (a.context) obj.contexts = { [a.context]: true };
// @ts-expect-error - dynamic build
addressesMap[`a${i}`] = obj;
});
const onlineServicesMap: Record<string, ContactOnlineService> = {};
onlineServices.filter(s => s.uri.trim()).forEach((s, i) => {
const obj: ContactOnlineService = { uri: s.uri.trim() };
if (s.service.trim()) obj.service = s.service.trim();
if (s.label.trim()) obj.label = s.label.trim();
onlineServicesMap[`os${i}`] = obj;
});
const anniversariesMap: Record<string, ContactAnniversary> = {};
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
anniversariesMap[`an${i}`] = { date: a.date.trim(), kind: a.kind };
});
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
personalInfoEntries.filter(p => p.value.trim()).forEach((p, i) => {
const obj: ContactPersonalInfo = { value: p.value.trim(), kind: p.kind };
if (p.level) obj.level = p.level as "high" | "medium" | "low";
personalInfoMap[`pi${i}`] = obj;
});
const keywordsMap: Record<string, boolean> = {};
if (keywordsStr.trim()) {
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
keywordsMap[k] = true;
});
}
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
organizations: organization.trim()
? { o0: { name: organization.trim() } }
? { o0: { name: organization.trim(), units: orgUnits } }
: undefined,
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
anniversaries: Object.keys(anniversariesMap).length > 0 ? anniversariesMap : undefined,
personalInfo: Object.keys(personalInfoMap).length > 0 ? personalInfoMap : undefined,
keywords: Object.keys(keywordsMap).length > 0 ? keywordsMap : undefined,
notes: note.trim()
? { n0: { note: note.trim() } }
: undefined,
gender: (genderSex.trim() || genderIdentity.trim())
? { sex: genderSex.trim() || undefined, identity: genderIdentity.trim() || undefined }
: undefined,
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
};
setIsSaving(true);
@@ -150,186 +390,402 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
};
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
<button type="button" onClick={onCancel} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded">
{error}
</div>
)}
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input
value={givenName}
onChange={(e) => setGivenName(e.target.value)}
placeholder={t("given_name")}
autoFocus
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input
value={surname}
onChange={(e) => setSurname(e.target.value)}
placeholder={t("surname")}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("email")}</label>
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i}>
<div className="flex items-center gap-2">
<Input
type="email"
inputMode="email"
value={entry.address}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], address: e.target.value };
setEmails(next);
if (emailErrors[i]) {
setEmailErrors(prev => {
const n = { ...prev };
delete n[i];
return n;
});
}
}}
onBlur={() => handleEmailBlur(i, entry.address)}
placeholder={t("email_placeholder")}
className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")}
/>
<select
value={entry.context}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], context: e.target.value as EmailEntry["context"] };
setEmails(next);
}}
className="text-sm bg-transparent border rounded px-2 py-2 text-foreground"
>
<option value=""></option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</select>
{emails.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setEmails(emails.filter((_, j) => j !== i))}
className="h-8 w-8"
{/* Name & Identity — full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
</FormSection>
</div>
{/* Email */}
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i}>
<div className="flex items-center gap-2">
<Input
type="email"
inputMode="email"
value={entry.address}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], address: e.target.value };
setEmails(next);
if (emailErrors[i]) {
setEmailErrors(prev => { const n = { ...prev }; delete n[i]; return n; });
}
}}
onBlur={() => handleEmailBlur(i, entry.address)}
placeholder={t("email_placeholder")}
className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")}
/>
<Select
value={entry.context}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], context: e.target.value as EmailEntry["context"] };
setEmails(next);
}}
>
<option value=""></option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
{emails.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => setEmails(emails.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ml-1">{emailErrors[i]}</p>
)}
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_email")}
</Button>
</div>
</FormSection>
{/* Phone */}
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
<div className="space-y-2">
{phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="tel"
inputMode="tel"
value={entry.number}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], number: e.target.value };
setPhones(next);
}}
placeholder={t("phone_placeholder")}
className="flex-1"
/>
<Select
value={entry.feature}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], feature: e.target.value as PhoneEntry["feature"] };
setPhones(next);
}}
className="w-[5.5rem]"
>
<option value="">{t("phone_type")}</option>
<option value="voice">{t("phone_voice")}</option>
<option value="cell">{t("phone_cell")}</option>
<option value="fax">{t("phone_fax")}</option>
<option value="pager">{t("phone_pager")}</option>
<option value="video">{t("phone_video")}</option>
<option value="text">{t("phone_text")}</option>
</Select>
<Select
value={entry.context}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], context: e.target.value as PhoneEntry["context"] };
setPhones(next);
}}
>
<option value=""></option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setPhones(phones.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{emailErrors[i]}</p>
)}
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEmails([...emails, { address: "", context: "" }])}
>
<Plus className="w-3 h-3 mr-1" />
{t("add_email")}
</Button>
</div>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_phone")}
</Button>
</div>
</FormSection>
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("phone")}</label>
<div className="space-y-2">
{phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="tel"
inputMode="tel"
value={entry.number}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], number: e.target.value };
setPhones(next);
}}
placeholder={t("phone_placeholder")}
className="flex-1"
/>
<select
value={entry.context}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], context: e.target.value as PhoneEntry["context"] };
setPhones(next);
}}
className="text-sm bg-transparent border rounded px-2 py-2 text-foreground"
>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("job_title")}</label>
<Input value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} placeholder={t("job_title_placeholder")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("role")}</label>
<Input value={role} onChange={(e) => setRole(e.target.value)} placeholder={t("role_placeholder")} />
</div>
</div>
</FormSection>
{/* Addresses — full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
<div className="space-y-3">
{addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
<Button type="button" variant="ghost" size="icon" onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="h-6 w-6 absolute top-2 right-2">
<X className="w-3 h-3" />
</Button>
<Input value={addr.street} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} />
<div className="grid grid-cols-2 gap-2">
<Input value={addr.locality} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} />
<Input value={addr.region} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], region: e.target.value }; setAddresses(n); }} placeholder={t("region")} />
</div>
<div className="grid grid-cols-3 gap-2">
<Input value={addr.postcode} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} />
<Input value={addr.country} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} />
<Select
value={addr.context}
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
>
<option value=""></option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
</div>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_address")}
</Button>
</div>
</FormSection>
</div>
{/* Online Services */}
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
<div className="space-y-2">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={svc.uri}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }}
placeholder={t("url_placeholder")}
className="flex-1"
/>
<Input
value={svc.service}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }}
placeholder={t("service_placeholder")}
className="w-24"
/>
<Button type="button" variant="ghost" size="icon" onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_online_service")}
</Button>
</div>
</FormSection>
{/* Anniversaries */}
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
<div className="space-y-2">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="date"
value={ann.date}
onChange={(e) => { const n = [...anniversaries]; n[i] = { ...n[i], date: e.target.value }; setAnniversaries(n); }}
className="flex-1"
/>
<Select
value={ann.kind}
onChange={(e) => { const n = [...anniversaries]; n[i] = { ...n[i], kind: e.target.value as AnniversaryEntry["kind"] }; setAnniversaries(n); }}
>
<option value="birth">{t("anniversary_birth")}</option>
<option value="wedding">{t("anniversary_wedding")}</option>
<option value="death">{t("anniversary_death")}</option>
<option value="other">{t("anniversary_other")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setAnniversaries(anniversaries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_anniversary")}
</Button>
</div>
</FormSection>
{/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
<div className="space-y-2">
{personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={pi.value}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }}
placeholder={t("personal_info_placeholder")}
className="flex-1"
/>
<Select
value={pi.kind}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], kind: e.target.value as PersonalInfoEntry["kind"] }; setPersonalInfoEntries(n); }}
>
<option value="expertise">{t("personal_expertise")}</option>
<option value="hobby">{t("personal_hobby")}</option>
<option value="interest">{t("personal_interest")}</option>
<option value="other">{t("personal_other")}</option>
</Select>
<Select
value={pi.level}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], level: e.target.value as PersonalInfoEntry["level"] }; setPersonalInfoEntries(n); }}
>
<option value="">{t("level")}</option>
<option value="high">{t("level_high")}</option>
<option value="medium">{t("level_medium")}</option>
<option value="low">{t("level_low")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_personal_info")}
</Button>
</div>
</FormSection>
{/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
<div>
<Input
value={keywordsStr}
onChange={(e) => setKeywordsStr(e.target.value)}
placeholder={t("categories_placeholder")}
/>
<p className="text-xs text-muted-foreground mt-1.5">{t("categories_hint")}</p>
</div>
</FormSection>
{/* Gender */}
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
<option value=""></option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setPhones(phones.filter((_, j) => j !== i))}
className="h-8 w-8"
>
<X className="w-3 h-3" />
</Button>
<option value="M">{t("gender_male")}</option>
<option value="F">{t("gender_female")}</option>
<option value="O">{t("gender_other")}</option>
<option value="N">{t("gender_none")}</option>
<option value="U">{t("gender_unknown")}</option>
</Select>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPhones([...phones, { number: "", context: "" }])}
>
<Plus className="w-3 h-3 mr-1" />
{t("add_phone")}
</Button>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_identity")}</label>
<Input value={genderIdentity} onChange={(e) => setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} />
</div>
</div>
</FormSection>
{/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
<Input value={calendarUri} onChange={(e) => setCalendarUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("scheduling_uri")}</label>
<Input value={schedulingUri} onChange={(e) => setSchedulingUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("freebusy_uri")}</label>
<Input value={freeBusyUri} onChange={(e) => setFreeBusyUri(e.target.value)} placeholder="https://..." />
</div>
</div>
</FormSection>
{/* Notes — full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("note_placeholder")}
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/>
</FormSection>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("organization")}</label>
<Input
value={organization}
onChange={(e) => setOrganization(e.target.value)}
placeholder={t("organization_placeholder")}
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t("note")}</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("note_placeholder")}
className="w-full min-h-[80px] rounded border bg-transparent px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
{t("cancel")}
</Button>
+13 -8
View File
@@ -15,6 +15,7 @@ interface ContactGroupDetailProps {
onDelete: () => void;
onRemoveMember: (memberId: string) => void;
onSelectMember: (id: string) => void;
isMobile?: boolean;
className?: string;
}
@@ -25,6 +26,7 @@ export function ContactGroupDetail({
onDelete,
onRemoveMember,
onSelectMember,
isMobile,
className,
}: ContactGroupDetailProps) {
const t = useTranslations("contacts");
@@ -32,21 +34,21 @@ export function ContactGroupDetail({
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border">
<div className="flex items-start justify-between">
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<Users className="w-7 h-7 text-primary" />
<div className={cn("rounded-full bg-primary/10 flex items-center justify-center", isMobile ? "w-12 h-12" : "w-14 h-14")}>
<Users className={cn("text-primary", isMobile ? "w-6 h-6" : "w-7 h-7")} />
</div>
<div>
<h2 className="text-xl font-semibold">{groupName}</h2>
<h2 className={cn("font-semibold", isMobile ? "text-lg" : "text-xl")}>{groupName}</h2>
<p className="text-sm text-muted-foreground">
{t("groups.member_count", { count: members.length })}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
@@ -54,7 +56,7 @@ export function ContactGroupDetail({
variant="outline"
size="sm"
onClick={onDelete}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950"
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation"
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -95,7 +97,10 @@ export function ContactGroupDetail({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
className={cn(
"h-8 w-8 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={() => onRemoveMember(member.id)}
>
<UserMinus className="w-4 h-4 text-muted-foreground" />
+2 -1
View File
@@ -69,10 +69,11 @@ export function ContactGroupList({
key={group.id}
onClick={() => onSelectGroup(group.id)}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
"w-full flex items-center px-4 text-left transition-colors",
"hover:bg-muted",
group.id === selectedGroupId && "bg-accent text-accent-foreground"
)}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
+41 -10
View File
@@ -4,14 +4,20 @@ import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { CheckSquare, Square } from "lucide-react";
import type { Density } from "@/stores/settings-store";
interface ContactListItemProps {
contact: ContactCard;
isSelected: boolean;
onClick: () => void;
isChecked: boolean;
hasSelection: boolean;
density: Density;
onClick: (e: React.MouseEvent) => void;
onCheckboxClick: (e: React.MouseEvent) => void;
}
export function ContactListItem({ contact, isSelected, onClick }: ContactListItemProps) {
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, onClick, onCheckboxClick }: ContactListItemProps) {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const org = contact.organizations
@@ -19,26 +25,51 @@ export function ContactListItem({ contact, isSelected, onClick }: ContactListIte
: undefined;
return (
<button
<div
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
"hover:bg-muted",
isSelected && "bg-accent text-accent-foreground"
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
isSelected
? "bg-blue-200 dark:bg-blue-900/50 shadow-sm"
: "bg-background hover:bg-muted hover:shadow-sm",
isChecked && !isSelected && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
)}
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
>
<Avatar name={name} email={email} size="sm" />
{hasSelection && (
<button
onClick={onCheckboxClick}
className={cn(
"p-1 rounded flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{density !== 'extra-compact' && (
<Avatar name={name} email={email} size="sm" className="flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{name || email || "—"}
</div>
{email && name && (
{density !== 'extra-compact' && email && name && (
<div className="text-xs text-muted-foreground truncate">{email}</div>
)}
{org && (
{density === 'comfortable' && org && (
<div className="text-xs text-muted-foreground truncate">{org}</div>
)}
</div>
</button>
</div>
);
}
+79 -92
View File
@@ -2,13 +2,14 @@
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus, Upload } from "lucide-react";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";
interface ContactListProps {
contacts: ContactCard[];
@@ -17,11 +18,11 @@ interface ContactListProps {
onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void;
onCreateNew: () => void;
onImport?: () => void;
supportsSync: boolean;
categoryLabel: string;
className?: string;
selectedContactIds: Set<string>;
onToggleSelection: (id: string) => void;
onSelectRangeContacts: (id: string, sortedIds: string[]) => void;
onSelectAll: (ids: string[]) => void;
onClearSelection: () => void;
onBulkDelete: () => void;
@@ -36,11 +37,11 @@ export function ContactList({
onSearchChange,
onSelectContact,
onCreateNew,
onImport,
supportsSync,
categoryLabel,
className,
selectedContactIds,
onToggleSelection,
onSelectRangeContacts,
onSelectAll,
onClearSelection,
onBulkDelete,
@@ -48,18 +49,27 @@ export function ContactList({
onBulkExport,
}: ContactListProps) {
const t = useTranslations("contacts");
const density = useSettingsStore((state) => state.density);
const filtered = useMemo(() => {
const individuals = contacts.filter(c => c.kind !== "group");
if (!searchQuery) return individuals;
if (!searchQuery) return contacts;
const lower = searchQuery.toLowerCase();
return individuals.filter((c) => {
return contacts.filter((c) => {
const name = getContactDisplayName(c).toLowerCase();
const emails = c.emails
? Object.values(c.emails).map((e) => e.address.toLowerCase())
: [];
const phones = c.phones
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "")
: [];
const org = c.organizations
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
: [];
return (
name.includes(lower) || emails.some((e) => e.includes(lower))
name.includes(lower) ||
emails.some((e) => e.includes(lower)) ||
phones.some((p) => p.includes(lower)) ||
org.some((o) => o.includes(lower))
);
});
}, [contacts, searchQuery]);
@@ -72,41 +82,51 @@ export function ContactList({
});
}, [filtered]);
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
const hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="px-4 py-3 border-b border-border space-y-3">
{/* Search header */}
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("title")}</h2>
<Button size="sm" onClick={onCreateNew}>
<Plus className="w-4 h-4 mr-1" />
{t("create_new")}
</Button>
<span className="text-xs font-medium text-muted-foreground truncate">
{categoryLabel} ({contacts.length})
</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
className="pl-8 h-8 text-sm"
/>
</div>
{!supportsSync && (
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted rounded px-3 py-2">
<Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t("local_mode")}</span>
</div>
)}
</div>
{/* Bulk action bar */}
{hasSelection && (
<div className="px-3 py-2 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-muted-foreground">
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
<button
onClick={() => {
if (allSelected) {
onClearSelection();
} else {
onSelectAll(sortedIds);
}
}}
className="p-1 rounded hover:bg-muted/50 transition-colors"
>
{allSelected ? (
<CheckSquare className="w-4 h-4 text-primary" />
) : (
<Square className="w-4 h-4 text-muted-foreground" />
)}
</button>
<span className="text-xs font-medium text-foreground">
{t("bulk.selected", { count: selectedContactIds.size })}
</span>
<div className="flex-1" />
@@ -133,43 +153,19 @@ export function ContactList({
</div>
)}
{sorted.length > 0 && (
<div className="px-4 py-1.5 border-b border-border flex items-center">
<button
onClick={() => {
if (allSelected) {
onClearSelection();
} else {
onSelectAll(sorted.map(c => c.id));
}
}}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
allSelected
? "bg-primary border-primary text-primary-foreground"
: "border-border"
)}>
{allSelected && <Check className="w-2.5 h-2.5" />}
</div>
{t("bulk.select_all")}
</button>
</div>
)}
{/* Contact list */}
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
{searchQuery ? (
<>
<Search className="w-12 h-12 mb-3 text-muted-foreground/30" />
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
className="mt-3"
onClick={() => onSearchChange("")}
>
{t("clear_search")}
@@ -177,52 +173,43 @@ export function ContactList({
</>
) : (
<>
<BookUser className="w-12 h-12 mb-3 text-muted-foreground/30" />
<BookUser className="w-10 h-10 mb-3 text-muted-foreground/30" />
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
<div className="flex gap-2 mt-4">
<Button size="sm" onClick={onCreateNew}>
<UserPlus className="w-4 h-4 mr-1.5" />
{t("create_new")}
</Button>
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1.5" />
{t("import_vcard")}
</Button>
)}
</div>
<Button size="sm" className="mt-3" onClick={onCreateNew}>
<UserPlus className="w-4 h-4 mr-1.5" />
{t("create_new")}
</Button>
</>
)}
</div>
) : (
<div className="divide-y divide-border">
<div>
{sorted.map((contact) => (
<div key={contact.id} className="flex items-center">
<button
onClick={(e) => {
e.stopPropagation();
<ContactListItem
key={contact.id}
contact={contact}
isSelected={contact.id === selectedContactId}
isChecked={selectedContactIds.has(contact.id)}
hasSelection={hasSelection}
density={density}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
onToggleSelection(contact.id);
}}
className="pl-4 pr-1 py-3 flex-shrink-0"
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
selectedContactIds.has(contact.id)
? "bg-primary border-primary text-primary-foreground"
: "border-border hover:border-muted-foreground"
)}>
{selectedContactIds.has(contact.id) && <Check className="w-2.5 h-2.5" />}
</div>
</button>
<div className="flex-1 min-w-0">
<ContactListItem
contact={contact}
isSelected={contact.id === selectedContactId}
onClick={() => onSelectContact(contact.id)}
/>
</div>
</div>
} else if (e.shiftKey) {
e.preventDefault();
onSelectRangeContacts(contact.id, sortedIds);
} else {
if (hasSelection) onClearSelection();
onSelectContact(contact.id);
}
}}
onCheckboxClick={(e) => {
e.stopPropagation();
onToggleSelection(contact.id);
}}
/>
))}
</div>
)}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
export type ContactCategory = "all" | { groupId: string };
interface ContactsSidebarProps {
groups: ContactCard[];
individuals: ContactCard[];
activeCategory: ContactCategory;
onSelectCategory: (category: ContactCategory) => void;
onCreateGroup: () => void;
onCreateContact: () => void;
className?: string;
}
export function ContactsSidebar({
groups,
individuals,
activeCategory,
onSelectCategory,
onCreateGroup,
onCreateContact,
className,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
const sortedGroups = useMemo(() => {
return [...groups].sort((a, b) =>
getContactDisplayName(a).localeCompare(getContactDisplayName(b))
);
}, [groups]);
const isAllActive = activeCategory === "all";
return (
<div className={cn("flex flex-col h-full bg-secondary", className)}>
{/* Header */}
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
<span className="text-sm font-semibold truncate">{t("title")}</span>
<Button size="icon" variant="ghost" onClick={onCreateContact} className="h-7 w-7 flex-shrink-0">
<UserPlus className="w-4 h-4" />
</Button>
</div>
{/* Categories */}
<div className="flex-1 overflow-y-auto py-1">
{/* All contacts */}
<button
onClick={() => onSelectCategory("all")}
className={cn(
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
isAllActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("tabs.all")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{individuals.length}
</span>
</button>
{/* Groups section */}
{(sortedGroups.length > 0) && (
<div className="mt-2">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("tabs.groups")}
</span>
<Button size="icon" variant="ghost" onClick={onCreateGroup} className="h-5 w-5">
<Plus className="w-3 h-3" />
</Button>
</div>
{sortedGroups.map((group) => {
const isActive = typeof activeCategory === "object" && activeCategory.groupId === group.id;
const memberCount = group.members
? Object.values(group.members).filter(Boolean).length
: 0;
return (
<button
key={group.id}
onClick={() => onSelectCategory({ groupId: group.id })}
className={cn(
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<Users className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{getContactDisplayName(group)}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{memberCount}
</span>
</button>
);
})}
</div>
)}
{sortedGroups.length === 0 && (
<div className="mt-2 px-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("tabs.groups")}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={onCreateGroup}
className="w-full justify-start text-xs text-muted-foreground h-7"
>
<Plus className="w-3 h-3 mr-1.5" />
{t("groups.create")}
</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,635 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CalendarInvitationBanner } from '../calendar-invitation-banner';
import type { Email } from '@/lib/jmap/types';
const mocks = vi.hoisted(() => {
const pushMock = vi.fn();
const clientMock = {
parseCalendarEvents: vi.fn(),
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
getCalendarEvent: vi.fn(),
queryCalendarEvents: vi.fn(async () => []),
};
const authState = {
client: clientMock,
primaryIdentity: { email: 'user@example.com' },
};
const settingsState = {
calendarInvitationParsingEnabled: true,
};
const calendarState = {
calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }],
supportsCalendar: true,
importEvents: vi.fn(),
rsvpEvent: vi.fn(),
updateEvent: vi.fn(),
events: [] as Array<Record<string, unknown>>,
setSelectedDate: vi.fn(),
};
const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => (
typeof selector === 'function' ? selector(calendarState) : calendarState
)) as {
(selector?: (state: typeof calendarState) => unknown): unknown;
getState: () => typeof calendarState;
setState: (updater: Partial<typeof calendarState> | ((state: typeof calendarState) => Partial<typeof calendarState> | typeof calendarState)) => void;
};
useCalendarStoreMock.getState = () => calendarState;
useCalendarStoreMock.setState = (updater) => {
const nextState = typeof updater === 'function' ? updater(calendarState) : updater;
Object.assign(calendarState, nextState);
};
return {
pushMock,
clientMock,
authState,
settingsState,
calendarState,
useCalendarStoreMock,
};
});
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, string>) => {
const strings: Record<string, string> = {
loading: 'Loading event details…',
title: 'Calendar Invitation',
organizer: 'Organized by {name}',
attendees: 'attendees',
add_to_calendar: 'Add to calendar',
added: 'Added to calendar',
rsvp_sent: 'Response sent',
already_in_calendar: 'Already in your calendar',
your_response: 'Your response: {status}',
response_accepted: 'Accepted',
response_needed: 'Needs response',
actor_response_info: '{name} responded {status}.',
actor_sent_info: 'Sent by {name}.',
actor_counter_info: '{name} proposed changes to this event.',
actor_refresh_info: '{name} asked for the latest event details.',
actor_declined_counter_info: '{name} declined the counter proposal.',
actor_note: 'Note: {comment}',
actor_unknown: 'Someone',
sender_mismatch_unverified_info: 'This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}, and the message could not be verified.',
action_failed: 'Could not complete that calendar action.',
proposal_applied: 'Proposed changes applied.',
proposed_changes: 'Proposed changes',
change_title: 'Title',
change_time: 'Time',
change_location: 'Location',
change_description: 'Description',
change_empty: 'None',
change_from_to: '{before} -> {after}',
apply_proposal: 'Apply proposed changes',
review_proposal: 'Review proposal',
review_request: 'Review request',
view_in_calendar: 'View in calendar',
organizer_role: 'You organize this event',
accept: 'Accept',
maybe: 'Maybe',
decline: 'Decline',
select_calendar: 'Select calendar',
};
let message = strings[key] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
message = message.replace(`{${name}}`, String(value));
}
}
return message;
},
useFormatter: () => ({
dateTime: (date: Date) => date.toISOString(),
}),
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: mocks.pushMock }),
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState),
}));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState),
}));
vi.mock('@/stores/calendar-store', () => ({
useCalendarStore: mocks.useCalendarStoreMock,
}));
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: {},
size: 512,
receivedAt: '2026-03-16T10:00:00Z',
hasAttachment: true,
attachments: [
{
partId: '1',
blobId: 'blob-1',
size: 256,
type: 'text/calendar; method=REQUEST',
name: 'invite.ics',
},
],
...overrides,
};
}
describe('CalendarInvitationBanner', () => {
beforeEach(() => {
mocks.pushMock.mockReset();
mocks.settingsState.calendarInvitationParsingEnabled = true;
mocks.clientMock.parseCalendarEvents.mockReset();
mocks.clientMock.getCalendarEvent.mockReset();
mocks.clientMock.getCalendarEvent.mockResolvedValue(null);
mocks.clientMock.queryCalendarEvents.mockReset();
mocks.clientMock.queryCalendarEvents.mockResolvedValue([]);
mocks.calendarState.importEvents.mockReset();
mocks.calendarState.rsvpEvent.mockReset();
mocks.calendarState.updateEvent.mockReset();
mocks.calendarState.setSelectedDate.mockReset();
mocks.calendarState.events = [];
});
it('does not parse invitations when invitation parsing is disabled', () => {
mocks.settingsState.calendarInvitationParsingEnabled = false;
const { container } = render(<CalendarInvitationBanner email={makeEmail()} />);
expect(container.firstChild).toBeNull();
expect(mocks.clientMock.parseCalendarEvents).not.toHaveBeenCalled();
});
it('does not show trust warnings on sent invitations from the current user', async () => {
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-sent',
title: 'My Sent Invite',
start: '2026-03-22T14:00:00Z',
organizerCalendarAddress: 'mailto:other-organizer@example.com',
participants: {
organizer: {
'@type': 'Participant',
calendarAddress: 'mailto:other-organizer@example.com',
name: 'Other Organizer',
roles: { owner: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
email: 'guest@example.com',
name: 'Guest',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
]);
render(
<CalendarInvitationBanner
email={makeEmail({
id: 'email-sent',
from: [{ email: 'user@example.com', name: 'User' }],
})}
/>,
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'My Sent Invite' })).toBeInTheDocument();
});
expect(screen.queryByText(/sent from/i)).not.toBeInTheDocument();
});
it('shows existing event status, current response, and view-in-calendar action', async () => {
mocks.calendarState.events = [
{
id: 'event-1',
uid: 'uid-1',
start: '2026-03-20T09:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'accepted',
},
},
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-1',
title: 'Team Sync',
start: '2026-03-20T09:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'accepted',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail()} />);
await waitFor(() => {
expect(screen.getByText('Already in your calendar')).toBeInTheDocument();
});
expect(screen.getByText('Your response: Accepted')).toBeInTheDocument();
fireEvent.click(screen.getByText('View in calendar'));
expect(mocks.calendarState.setSelectedDate).toHaveBeenCalledTimes(1);
expect(mocks.pushMock).toHaveBeenCalledWith('/calendar');
});
it('keeps the invitation summary visible after importing', async () => {
mocks.calendarState.importEvents.mockResolvedValue(1);
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-2',
title: 'Planning Session',
start: '2026-03-22T13:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-2' })} />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Add to calendar'));
await waitFor(() => {
expect(screen.getByText('Added to calendar')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument();
});
it('does not show RSVP actions when the current user is the organizer', async () => {
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-3',
title: 'Organizer Review',
start: '2026-03-25T11:00:00Z',
participants: {
organizer: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { owner: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
email: 'alice@example.com',
name: 'Alice',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-3' })} />);
await waitFor(() => {
expect(screen.getByText('You organize this event')).toBeInTheDocument();
});
expect(screen.queryByText('Accept')).not.toBeInTheDocument();
expect(screen.queryByText('Maybe')).not.toBeInTheDocument();
expect(screen.queryByText('Decline')).not.toBeInTheDocument();
});
it('keeps the banner visible when an import action fails', async () => {
mocks.calendarState.importEvents.mockResolvedValue(0);
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-4',
title: 'Failed Import Event',
start: '2026-03-26T15:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-4' })} />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Add to calendar'));
await waitFor(() => {
expect(screen.getByText('Could not complete that calendar action.')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument();
});
it('hydrates sparse existing events before sending RSVP', async () => {
mocks.calendarState.events = [
{
id: 'event-6',
uid: 'uid-6',
start: '2026-03-28T09:00:00Z',
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-6',
title: 'Sparse Event',
start: '2026-03-28T09:00:00Z',
participants: {
parsedAttendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { required: true },
participationStatus: 'needs-action',
},
},
},
]);
mocks.clientMock.getCalendarEvent.mockResolvedValue({
id: 'event-6',
uid: 'uid-6',
start: '2026-03-28T09:00:00Z',
participants: {
canonicalAttendee: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
});
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6' })} />);
await waitFor(() => {
expect(screen.getByText('Accept')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => {
expect(mocks.clientMock.getCalendarEvent).toHaveBeenCalledWith('event-6');
});
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
mocks.clientMock,
'event-6',
'canonicalAttendee',
'accepted',
null,
);
});
it('falls back to organizerCalendarAddress when replyTo is missing', async () => {
mocks.calendarState.events = [
{
id: 'event-7',
uid: 'uid-7',
start: '2026-03-29T09:00:00Z',
participants: {
attendee: {
'@type': 'Participant',
calendarAddress: 'mailto:user@example.com',
name: 'User',
roles: { attendee: true },
participationStatus: 'needs-action',
},
},
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-7',
title: 'Invite Without Reply-To',
start: '2026-03-29T09:00:00Z',
organizerCalendarAddress: 'mailto:organizer@example.com',
participants: {
attendee: {
'@type': 'Participant',
calendarAddress: 'mailto:user@example.com',
name: 'User',
roles: { required: true },
participationStatus: 'needs-action',
},
},
},
]);
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-7' })} />);
await waitFor(() => {
expect(screen.getByText('Accept')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => {
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
mocks.clientMock,
'event-7',
'attendee',
'accepted',
{ imip: 'mailto:organizer@example.com' },
);
});
});
it('repairs sparse existing events with parsed participants before RSVPing', async () => {
mocks.calendarState.events = [
{
id: 'event-8',
uid: 'uid-8',
start: '2026-03-30T09:00:00Z',
},
];
mocks.clientMock.getCalendarEvent.mockResolvedValue({
id: 'event-8',
uid: 'uid-8',
start: '2026-03-30T09:00:00Z',
});
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-8',
title: 'Sparse Stored Event',
start: '2026-03-30T09:00:00Z',
organizerCalendarAddress: 'mailto:organizer@example.com',
participants: {
organizer: {
'@type': 'Participant',
calendarAddress: 'mailto:organizer@example.com',
name: 'Organizer',
roles: { required: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
calendarAddress: 'mailto:user@example.com',
name: 'User',
roles: { required: true },
participationStatus: 'needs-action',
},
},
},
]);
mocks.calendarState.updateEvent.mockResolvedValue(undefined);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-8' })} />);
await waitFor(() => {
expect(screen.getByText('Accept')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => {
expect(mocks.calendarState.updateEvent).toHaveBeenCalledWith(
mocks.clientMock,
'event-8',
expect.objectContaining({
replyTo: { imip: 'mailto:organizer@example.com' },
participants: expect.objectContaining({
attendee: expect.objectContaining({
participationStatus: 'accepted',
}),
}),
}),
true,
);
});
expect(mocks.calendarState.rsvpEvent).not.toHaveBeenCalled();
});
it('shows counter changes and lets organizers apply the proposal', async () => {
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined);
mocks.calendarState.events = [
{
id: 'event-5',
uid: 'uid-5',
title: 'Original Sync',
start: '2026-03-27T09:00:00Z',
duration: 'PT1H',
locations: {
room: { '@type': 'Location', name: 'Room A' },
},
participants: {
organizer: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { owner: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
email: 'alice@example.com',
name: 'Alice',
roles: { attendee: true },
participationStatus: 'tentative',
participationComment: 'Could we move this later?',
},
},
},
];
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
{
uid: 'uid-5',
title: 'Original Sync',
start: '2026-03-27T10:00:00Z',
duration: 'PT1H',
locations: {
room: { '@type': 'Location', name: 'Room B' },
},
participants: {
organizer: {
'@type': 'Participant',
email: 'user@example.com',
name: 'User',
roles: { owner: true },
participationStatus: 'accepted',
},
attendee: {
'@type': 'Participant',
email: 'alice@example.com',
name: 'Alice',
roles: { attendee: true },
participationStatus: 'tentative',
participationComment: 'Could we move this later?',
},
},
},
]);
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-5', attachments: [{ partId: '1', blobId: 'blob-5', size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }] })} />);
await waitFor(() => {
expect(screen.getByText('Proposed changes')).toBeInTheDocument();
});
expect(screen.getByText(/Location/)).toBeInTheDocument();
expect(screen.getByText('Review proposal')).toBeInTheDocument();
fireEvent.click(screen.getByText('Apply proposed changes'));
await waitFor(() => {
expect(mocks.calendarState.updateEvent).toHaveBeenCalledTimes(1);
});
expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument();
});
});
@@ -0,0 +1,107 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EmailListItem } from '../email-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import type { Email } from '@/lib/jmap/types';
// Mock the drag hook
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
// Mock identity badge
vi.mock('../email-identity-badge', () => ({
EmailIdentityBadge: () => null,
}));
// Mock auth store
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
describe('EmailListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show tag badge when email has no label keyword', () => {
const email = makeEmail({ keywords: { $seen: true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
// No keyword label should appear
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows tag badge with label when email has $label: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows tag badge for legacy $color: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('does not show badge when keyword id not in settings', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
render(<EmailListItem email={email} />);
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
});
it('shows custom keyword label', () => {
useSettingsStore.setState({
emailKeywords: [
...DEFAULT_KEYWORDS,
{ id: 'work', label: 'Work', color: 'teal' },
],
});
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('updates badge when keyword definition changes', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
// Update label name
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
rerender(<EmailListItem email={email} />);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
it('renders subject even without tag', () => {
const email = makeEmail({ subject: 'Hello World' });
render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+136 -124
View File
@@ -19,8 +19,9 @@ import {
Trash2,
Archive,
FolderInput,
Palette,
Tag,
X,
Check,
Inbox,
Send,
File,
@@ -28,7 +29,8 @@ import {
ShieldAlert,
ShieldCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
interface Position {
x: number;
@@ -84,12 +86,14 @@ const getMailboxIcon = (role?: string) => {
}
};
// Get current color from email keywords
// Get current label/color from email keywords (supports both $label: and legacy $color:)
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
return key.replace("$color:", "");
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:")
? key.slice("$label:".length)
: key.slice("$color:".length);
}
}
return null;
@@ -125,31 +129,45 @@ export function EmailContextMenu({
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const currentColor = getCurrentColor(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Color options for email tags (using translations)
const colorOptions = [
{ name: tColor("red"), value: "red", color: "bg-red-500" },
{ name: tColor("orange"), value: "orange", color: "bg-orange-500" },
{ name: tColor("yellow"), value: "yellow", color: "bg-yellow-500" },
{ name: tColor("green"), value: "green", color: "bg-green-500" },
{ name: tColor("blue"), value: "blue", color: "bg-blue-500" },
{ name: tColor("purple"), value: "purple", color: "bg-purple-500" },
{ name: tColor("pink"), value: "pink", color: "bg-pink-500" },
];
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
}));
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
const moveTargets = mailboxes.filter(
(m) =>
m.id !== selectedMailbox &&
m.role !== "drafts" &&
!m.id.startsWith("shared-") &&
m.myRights?.mayAddItems
// Build mailbox tree for move-to submenu with proper hierarchy
const moveTargetIds = new Set(
mailboxes
.filter(
(m) =>
m.id !== selectedMailbox &&
m.role !== "drafts" &&
!m.id.startsWith("shared-") &&
m.myRights?.mayAddItems
)
.map((m) => m.id)
);
const mailboxTree = buildMailboxTree(mailboxes);
// Filter tree to only include branches that contain valid move targets
const filterTree = (nodes: MailboxNode[]): MailboxNode[] => {
return nodes.reduce<MailboxNode[]>((acc, node) => {
const filteredChildren = filterTree(node.children);
if (moveTargetIds.has(node.id) || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren });
}
return acc;
}, []);
};
const moveTree = filterTree(mailboxTree);
const handleAction = (action: () => void) => {
action();
@@ -195,19 +213,69 @@ export function EmailContextMenu({
</>
)}
{/* Mark as read/unread */}
{/* Archive */}
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMarkAsRead?.(isUnread)
: onMarkAsRead?.(isUnread)
)
}
icon={Archive}
label={t("archive")}
onClick={() => handleAction(onArchive!)}
disabled={!onArchive}
/>
{/* Delete */}
<ContextMenuItem
icon={Trash2}
label={t("delete")}
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
disabled={showBatchActions ? !onBatchDelete : !onDelete}
destructive
/>
<ContextMenuSeparator />
{/* Move to submenu */}
{moveTree.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
{(() => {
const renderNodes = (nodes: MailboxNode[]) => {
return nodes.map((node) => {
const Icon = getMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<ContextMenuItem
icon={Icon}
label={node.name}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMoveToMailbox?.(node.id)
: onMoveToMailbox?.(node.id)
)
}
/>
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && (
<div className="pl-4">
{renderNodes(node.children)}
</div>
)}
</div>
);
});
};
return renderNodes(moveTree);
})()}
</ContextMenuSubMenu>
)}
{/* Star/Unstar - only for single email */}
{!showBatchActions && (
<ContextMenuItem
@@ -218,39 +286,39 @@ export function EmailContextMenu({
/>
)}
<ContextMenuSeparator />
{/* Move to submenu */}
{moveTargets.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
{moveTargets.map((mailbox) => {
const Icon = getMailboxIcon(mailbox.role);
return (
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{currentColor === option.value && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
))}
{currentColor && (
<>
<ContextMenuSeparator />
<ContextMenuItem
key={mailbox.id}
icon={Icon}
label={mailbox.name}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMoveToMailbox?.(mailbox.id)
: onMoveToMailbox?.(mailbox.id)
)
}
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
);
})}
</>
)}
</ContextMenuSubMenu>
)}
{/* Archive */}
<ContextMenuItem
icon={Archive}
label={t("archive")}
onClick={() => handleAction(onArchive!)}
disabled={!onArchive}
/>
<ContextMenuSeparator />
{/* Spam - contextual based on folder */}
@@ -270,73 +338,17 @@ export function EmailContextMenu({
<ContextMenuSeparator />
{/* Set color submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
<div
className="px-3 py-2 flex flex-wrap gap-2"
role="group"
aria-label={t("color_tag")}
onKeyDown={(e) => {
const buttons = Array.from(
e.currentTarget.querySelectorAll<HTMLButtonElement>("button")
);
const idx = buttons.indexOf(e.target as HTMLButtonElement);
if (idx < 0) return;
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (idx + 1) % buttons.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (idx - 1 + buttons.length) % buttons.length;
}
if (next >= 0) {
e.preventDefault();
buttons[next].focus();
}
}}
>
{colorOptions.map((option, i) => (
<button
key={option.value}
tabIndex={i === 0 ? 0 : -1}
onClick={() =>
handleAction(() => onSetColorTag?.(option.value))
}
className={cn(
"w-8 h-8 rounded-full hover:scale-110 transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
option.color,
currentColor === option.value &&
"ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
title={option.name}
aria-label={option.name}
/>
))}
</div>
{currentColor && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
</>
)}
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
{/* Delete */}
{/* Mark as read/unread */}
<ContextMenuItem
icon={Trash2}
label={t("delete")}
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
handleAction(() =>
showBatchActions
? onBatchMarkAsRead?.(isUnread)
: onMarkAsRead?.(isUnread)
)
}
disabled={showBatchActions ? !onBatchDelete : !onDelete}
destructive
/>
</ContextMenu>
);
+95 -67
View File
@@ -1,16 +1,20 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback } from "react";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { Paperclip, Star, Circle, CheckSquare, Square, Tag } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { getEmailColorTag } from "@/lib/thread-utils";
interface EmailListItemProps {
email: Email;
@@ -19,39 +23,23 @@ interface EmailListItemProps {
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
}
// Color tag mapping - using lighter backgrounds for better readability
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
green: "bg-green-50 dark:bg-green-950/30",
blue: "bg-blue-50 dark:bg-blue-950/30",
purple: "bg-purple-50 dark:bg-purple-950/30",
pink: "bg-pink-50 dark:bg-pink-950/30",
} as const;
const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
const color = key.replace("$color:", "");
return colorTags[color as keyof typeof colorTags] || null;
}
}
return null;
};
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isImportant = email.keywords?.["$important"];
const sender = email.from?.[0];
const colorTag = getEmailColor(email.keywords);
// Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
@@ -59,6 +47,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
sourceMailboxId: selectedMailbox,
});
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
@@ -71,47 +72,61 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
return (
<div
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
? "bg-accent"
? "bg-blue-200 dark:bg-blue-900/50"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30",
isUnread && !selected && !colorTag && "bg-amber-50 dark:bg-amber-900/20",
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isChecked && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
// Long press visual feedback
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={onClick}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email.id);
} else {
if (selectedEmailIds.size > 0) clearSelection();
onClick?.();
}
}}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Checkbox with smooth animation */}
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{/* Unread indicator */}
{isUnread && (
@@ -121,12 +136,14 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
)}
{/* Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
)}
{/* Content */}
<div className="flex-1 min-w-0">
@@ -156,14 +173,25 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{/* Second Line: Subject */}
@@ -177,7 +205,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && (
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
+75 -41
View File
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -72,9 +72,9 @@ export function EmailList({
loadMoreEmails,
hasMoreEmails,
isLoadingMore,
totalEmails,
mailboxes,
selectedMailbox,
emptyMailbox,
expandedThreadIds,
threadEmailsCache,
isLoadingThread,
@@ -84,6 +84,7 @@ export function EmailList({
setSearchFilters,
clearSearchFilters,
advancedSearch,
searchQuery,
} = useEmailStore();
const threadGroups = useMemo(() => {
@@ -96,13 +97,13 @@ export function EmailList({
const [isProcessing, setIsProcessing] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
const listDensity = useSettingsStore((state) => state.listDensity);
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const estimateSize = useCallback(() => {
const base = { compact: 72, regular: 88, comfortable: 104 }[listDensity];
return showPreview ? base + 40 : base;
}, [listDensity, showPreview]);
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
}, [density, showPreview]);
const virtualizer = useVirtualizer({
count: threadGroups.length,
@@ -133,7 +134,6 @@ export function EmailList({
);
const hasSelection = selectedEmailIds.size > 0;
const allSelected = emails.length > 0 && emails.every(e => selectedEmailIds.has(e.id));
const handleBatchMarkAsRead = async (read: boolean) => {
if (!client || isProcessing) return;
@@ -148,10 +148,19 @@ export function EmailList({
const handleBatchDelete = async () => {
if (!client || isProcessing) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const confirmed = await confirmDialog({
title: t('batch_actions.delete_confirm_title'),
message: t('batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
confirmText: t('batch_actions.delete'),
title: isInTrash
? t('permanent_delete_confirm_title')
: t('batch_actions.delete_confirm_title'),
message: isInTrash
? t('permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
: t('batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
confirmText: isInTrash
? t('permanent_delete')
: t('batch_actions.delete'),
variant: "destructive",
});
if (!confirmed) return;
@@ -164,6 +173,28 @@ export function EmailList({
}
};
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isEmptyableFolder = currentMailbox?.role === 'trash' || currentMailbox?.role === 'junk';
const handleEmptyFolder = async () => {
if (!client || isProcessing || !currentMailbox) return;
const confirmed = await confirmDialog({
title: t('empty_folder.confirm_title'),
message: t('empty_folder.confirm_message'),
confirmText: t('empty_folder.confirm_button'),
variant: "destructive",
});
if (!confirmed) return;
setIsProcessing(true);
try {
await emptyMailbox(client, currentMailbox.id);
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
const handleLoadMore = useCallback(() => {
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
@@ -209,10 +240,10 @@ export function EmailList({
useEffect(() => {
virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listDensity, showPreview]);
}, [density, showPreview]);
return (
<div className={cn("flex flex-col h-full", className)}>
<div className={cn("flex flex-col min-h-0", className)}>
{/* Batch Actions Toolbar */}
<div
className={cn(
@@ -300,36 +331,29 @@ export function EmailList({
/>
)}
{/* List Header */}
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => allSelected ? clearSelection() : selectAllEmails()}
className={cn(
"p-1 rounded transition-all duration-200",
"hover:bg-muted hover:scale-110",
"active:scale-95",
allSelected && "text-primary"
)}
title={allSelected ? "Deselect all" : "Select all"}
{/* Empty Folder Banner for Junk/Trash */}
{isEmptyableFolder && emails.length > 0 && !hasSelection && (
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle className="w-4 h-4" />
<span>{currentMailbox?.role === 'junk' ? t('empty_folder.junk_hint') : t('empty_folder.trash_hint')}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleEmptyFolder}
disabled={isProcessing}
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
>
{allSelected ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
{isProcessing ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Square className="w-4 h-4" />
<Trash2 className="w-3 h-3 mr-1" />
)}
</button>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? t('loading') : threadGroups.length > 0
? (totalEmails !== undefined && totalEmails > threadGroups.length
? t('conversations_count', { count: threadGroups.length, total: totalEmails })
: hasMoreEmails
? t('conversations_count_plus', { count: threadGroups.length })
: t('conversations_count_simple', { count: threadGroups.length }))
: t('no_conversations')}
</h2>
{t('empty_folder.button')}
</Button>
</div>
</div>
)}
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
@@ -347,9 +371,19 @@ export function EmailList({
<LoadingSkeleton />
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<Inbox className="w-16 h-16 mb-4 text-muted-foreground/50" />
<p className="text-base font-medium text-foreground">{t('no_emails')}</p>
<p className="text-sm mt-1 text-muted-foreground">{t('no_emails_description')}</p>
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
{searchQuery || !isFilterEmpty(searchFilters) ? (
<SearchX className="w-10 h-10 text-muted-foreground" />
) : (
<MailX className="w-10 h-10 text-muted-foreground" />
)}
</div>
<p className="text-base font-medium text-foreground">
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
</p>
<p className="text-sm mt-1 text-muted-foreground">
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
</p>
</div>
) : (
<>
File diff suppressed because it is too large Load Diff
+221
View File
@@ -0,0 +1,221 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
import type { ContactCard } from "@/lib/jmap/types";
interface RecipientPopoverProps {
name?: string;
email: string;
/** Display label override (e.g. "me") */
displayLabel?: string;
/** Called when user clicks "View contact" — receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string;
}
export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const contacts = useContactStore((s) => s.contacts);
// Find matching contact by email
const contact = contacts.find((c) => {
if (!c.emails) return false;
return Object.values(c.emails).some(
(e) => e.address.toLowerCase() === email.toLowerCase()
);
});
const contactName = contact ? getContactDisplayName(contact) : name;
const emails = contact?.emails ? Object.values(contact.emails) : [];
const phones = contact?.phones ? Object.values(contact.phones) : [];
const orgs = contact?.organizations ? Object.values(contact.organizations) : [];
const handleOpen = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const popoverWidth = 300;
const popoverHeight = 250;
let top = rect.bottom + 4;
let left = rect.left;
// Keep within viewport
if (left + popoverWidth > window.innerWidth - 8) {
left = window.innerWidth - popoverWidth - 8;
}
if (left < 8) left = 8;
if (top + popoverHeight > window.innerHeight - 8) {
top = rect.top - popoverHeight - 4;
}
setPosition({ top, left });
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
setPosition(null);
};
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
handleClose();
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
const handleViewContact = () => {
if (onViewContact) {
onViewContact(contact ?? null, email);
}
handleClose();
};
const handleCopyEmail = async (addr: string) => {
try {
await navigator.clipboard.writeText(addr);
toast.success("Copied!");
} catch {
toast.error("Failed to copy");
}
};
return (
<>
<button
ref={triggerRef}
onClick={handleOpen}
className={cn(
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors",
className
)}
>
{displayLabel || name || email}
</button>
{isOpen &&
position &&
createPortal(
<div
ref={popoverRef}
className="fixed z-50 w-[300px] bg-background rounded-lg shadow-lg border border-border animate-in fade-in-0 zoom-in-95 duration-100"
style={{ top: position.top, left: position.left }}
>
{/* Header with avatar and name */}
<div className="px-4 pt-4 pb-3 flex items-center gap-3">
<Avatar
name={contactName || email}
email={email}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm truncate">
{contactName || email}
</div>
{contactName && contactName !== email && (
<div className="text-xs text-muted-foreground truncate">
{email}
</div>
)}
{orgs.length > 0 && orgs[0].name && (
<div className="text-xs text-muted-foreground truncate flex items-center gap-1">
<Building className="w-3 h-3 shrink-0" />
{orgs[0].name}
</div>
)}
</div>
</div>
{/* Contact details */}
<div className="px-4 pb-3 space-y-1.5">
{/* Show additional emails if contact has them */}
{emails.length > 1 && (
<div className="space-y-1">
{emails.slice(1).map((e, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="w-3 h-3 shrink-0" />
<span className="truncate">{e.address}</span>
</div>
))}
</div>
)}
{/* Phone numbers */}
{phones.length > 0 && (
<div className="space-y-1">
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<Phone className="w-3 h-3 shrink-0" />
<a href={`tel:${p.number}`} className="hover:text-foreground hover:underline truncate">
{p.number}
</a>
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="border-t border-border px-2 py-2 flex items-center gap-1">
<button
onClick={() => handleCopyEmail(email)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
title="Copy email"
>
<Copy className="w-3.5 h-3.5" />
Copy
</button>
<a
href={`mailto:${email}`}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
title="Send email"
>
<Send className="w-3.5 h-3.5" />
Email
</a>
{onViewContact && (
<button
onClick={handleViewContact}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ml-auto"
title={contact ? "View contact" : "View details"}
>
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
{contact ? "View contact" : "View details"}
</button>
)}
</div>
</div>,
document.body
)}
</>
);
}
+89 -18
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar";
@@ -26,9 +26,12 @@ import {
FileAudio,
FileArchive,
File,
Eye,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview";
interface ThreadConversationViewProps {
thread: ThreadGroup;
@@ -138,7 +141,7 @@ export function ThreadConversationView({
return (
<div className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10">
<div className="flex items-center px-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-header-py)' }}>
<button
onClick={onBack}
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
@@ -146,7 +149,7 @@ export function ThreadConversationView({
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1 min-w-0">
<h1 className="font-semibold text-foreground truncate">
<h1 className="font-semibold text-foreground break-words">
{thread.latestEmail.subject || t("email_viewer.no_subject")}
</h1>
<p className="text-sm text-muted-foreground">
@@ -157,7 +160,7 @@ export function ThreadConversationView({
{/* Email Cards */}
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-3">
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false;
@@ -220,10 +223,14 @@ function EmailCard({
}: EmailCardProps) {
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
const { client } = useAuthStore();
// Mark as read when email is expanded
useEffect(() => {
@@ -253,6 +260,51 @@ function EmailCard({
return () => clearTimeout(timeout);
}, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
if (!client || !email?.attachments) {
setCidBlobUrls({});
return;
}
const cidAttachments = email.attachments.filter(att => att.cid && att.blobId);
if (cidAttachments.length === 0) {
setCidBlobUrls({});
return;
}
let cancelled = false;
const objectUrls: string[] = [];
async function fetchCidBlobs() {
const urls: Record<string, string> = {};
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = att.cid!.replace(/^<|>$/g, '');
try {
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
} else {
URL.revokeObjectURL(objectUrl);
}
} catch {
// Failed to fetch inline image, will show placeholder
}
}));
if (!cancelled) {
setCidBlobUrls(urls);
}
}
fetchCidBlobs();
return () => {
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [client, email?.id]);
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false };
@@ -263,12 +315,21 @@ function EmailCard({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Use safe parsing instead of innerHTML to detect rich formatting
useHtmlVersion = hasRichFormatting(htmlContent);
useHtmlVersion = !!htmlContent;
}
if (useHtmlVersion && htmlContent) {
// Replace cid: references with authenticated blob URLs (fetched via useEffect)
// This prevents browser auth dialogs that occur when loading raw JMAP download URLs
if (email.attachments) {
htmlContent = htmlContent.replace(
/\bcid:([^"'\s)]+)/gi,
(_match, cidRef) => {
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
);
}
let blockedExternalContent = false;
// Use shared sanitization config as base (more secure)
@@ -354,7 +415,7 @@ function EmailCard({
}
return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme]);
}, [email, allowExternal, resolvedTheme, cidBlobUrls]);
return (
<div className={cn(
@@ -366,16 +427,19 @@ function EmailCard({
<button
onClick={onToggleExpanded}
className={cn(
"w-full flex items-start gap-3 p-4 text-left transition-colors",
"w-full flex items-start text-left transition-colors",
!isExpanded && "hover:bg-muted/50"
)}
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0"
/>
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={cn(
@@ -394,7 +458,7 @@ function EmailCard({
<div className="text-sm text-muted-foreground">
{formatDate(email.receivedAt)}
</div>
{!isExpanded && (
{!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{email.preview || "No preview available"}
</p>
@@ -446,7 +510,7 @@ function EmailCard({
)}
{/* Email Body */}
<div className="px-4 py-4">
<div style={{ padding: 'var(--density-card-p)' }}>
<div
className={cn(
"prose prose-sm max-w-none dark:prose-invert",
@@ -465,6 +529,8 @@ function EmailCard({
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<button
key={idx}
@@ -472,6 +538,7 @@ function EmailCard({
e.stopPropagation();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
}}
title={opensPreview ? t('files.preview') : t('email_viewer.download')}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
>
<Icon className="w-4 h-4 text-muted-foreground" />
@@ -479,7 +546,11 @@ function EmailCard({
<span className="text-muted-foreground text-xs">
{formatFileSize(attachment.size)}
</span>
<Download className="w-4 h-4 text-muted-foreground" />
{opensPreview ? (
<Eye className="w-4 h-4 text-muted-foreground" />
) : (
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
);
})}
+86 -13
View File
@@ -1,10 +1,16 @@
"use client";
import { useCallback } from "react";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle } from "lucide-react";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
interface ThreadEmailItemProps {
email: Email;
@@ -24,27 +30,92 @@ export function ThreadEmailItem({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density);
const isChecked = selectedEmailIds.has(email.id);
const { dragHandlers, isDragging } = useEmailDrag({
email,
sourceMailboxId: selectedMailbox,
});
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
};
const handleClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email.id);
} else {
if (selectedEmailIds.size > 0) clearSelection();
onClick?.();
}
};
return (
<div
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative cursor-pointer transition-all duration-150",
"pl-12 pr-4 py-2.5", // Indented for thread hierarchy
"relative cursor-pointer select-none transition-all duration-150",
"pl-12 pr-4",
"border-l-2 border-l-transparent",
selected
? "bg-accent border-l-primary"
? "bg-blue-200 dark:bg-blue-900/50 border-l-primary"
: "hover:bg-muted/50",
isUnread && !selected && "bg-accent/20",
!isLast && "border-b border-border/30"
isUnread && !selected && "bg-amber-50 dark:bg-amber-900/20",
!isLast && "border-b border-border/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={onClick}
onClick={handleClick}
onContextMenu={handleContextMenu}
style={{ paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex items-start gap-3">
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-1 rounded mt-0.5 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-3.5 h-3.5 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-3.5 h-3.5 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-7 top-1/2 -translate-y-1/2">
@@ -53,12 +124,14 @@ export function ThreadEmailItem({
)}
{/* Small Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="sm"
className="flex-shrink-0"
/>
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="sm"
className="flex-shrink-0"
/>
)}
{/* Content */}
<div className="flex-1 min-w-0">
+224 -64
View File
@@ -1,14 +1,17 @@
"use client";
import React from "react";
import React, { useCallback } from "react";
import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare } from "lucide-react";
import { useSettingsStore } from "@/stores/settings-store";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils";
import { useEmailStore } from "@/stores/email-store";
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
import { useTranslations } from "next-intl";
@@ -24,16 +27,6 @@ interface ThreadListItemProps {
onOpenConversation?: (thread: ThreadGroup) => void;
}
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
green: "bg-green-50 dark:bg-green-950/30",
blue: "bg-blue-50 dark:bg-blue-950/30",
purple: "bg-purple-50 dark:bg-purple-950/30",
pink: "bg-pink-50 dark:bg-pink-950/30",
} as const;
interface SingleEmailItemProps {
email: Email;
selected: boolean;
@@ -48,35 +41,103 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const isChecked = selectedEmailIds.has(email.id);
// Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
const resolvedColorTag = (() => {
if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
})();
const { dragHandlers, isDragging } = useEmailDrag({
email,
sourceMailboxId: selectedMailbox,
});
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
const handleClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email.id);
} else {
if (selectedEmailIds.size > 0) clearSelection();
onClick();
}
};
return (
<div
ref={ref}
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
colorTag ? colorTag : (
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
resolvedColorTag ? resolvedColorTag : (
selected
? "bg-accent"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30"
selected && !resolvedColorTag && "shadow-sm",
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={onClick}
onClick={handleClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
<div className="w-6 flex-shrink-0" />
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
@@ -84,12 +145,14 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
)}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
@@ -111,14 +174,25 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className={cn(
@@ -130,7 +204,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{email.subject || "(no subject)"}
</div>
{showPreview && (
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
@@ -161,15 +235,39 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
}, ref) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail,
sourceMailboxId: selectedMailbox,
threadEmails: thread.emails,
});
const { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel, isPressed: isThreadPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
latestEmail
);
}, [onContextMenu, latestEmail]),
isMobile
);
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
const threadColor = getThreadColorTag(thread.emails);
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
const isChecked = thread.emails.some(e => selectedEmailIds.has(e.id));
if (emailCount === 1) {
return (
<SingleEmailItem
@@ -186,7 +284,34 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const emailsToShow = expandedEmails || thread.emails;
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
// Toggle selection for all emails in this thread
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
const newSelection = new Set(selectedEmailIds);
thread.emails.forEach(em => {
if (allSelected) {
newSelection.delete(em.id);
} else {
newSelection.add(em.id);
}
});
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
};
const handleHeaderClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
// Ctrl+Click: toggle selection for all thread emails
thread.emails.forEach(em => toggleEmailSelection(em.id));
return;
}
if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(latestEmail.id);
return;
}
if (isMobile && onOpenConversation) {
onOpenConversation(thread);
return;
@@ -196,6 +321,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
if (selectedEmailIds.size > 0) clearSelection();
if (!isExpanded) {
onToggleExpand();
}
@@ -208,10 +334,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
};
return (
<div ref={ref} className="border-b border-border">
<div ref={ref} className={cn("border-b border-border", isThreadDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30")}>
<div
{...dragHandlers}
{...threadLongPressHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200",
"relative group cursor-pointer select-none transition-all duration-200",
colorTag ? colorTag : (
isSelected
? "bg-accent"
@@ -221,16 +349,35 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50"
isExpanded && "border-b border-border/50",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleHeaderClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
{/* Checkbox for thread selection - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleThreadCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{!isMobile && (
<button
data-expand-toggle
@@ -263,12 +410,14 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
)}
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{density !== 'extra-compact' && (
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
@@ -302,14 +451,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
<div className={cn(
@@ -321,7 +481,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{latestEmail.subject || "(no subject)"}
</div>
{showPreview && (
{showPreview && density !== 'extra-compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
+86 -37
View File
@@ -1,9 +1,11 @@
'use client';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl } from '@/lib/validation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useIsDesktop } from '@/hooks/use-media-query';
interface UnsubscribeBannerProps {
listUnsubscribe: {
@@ -25,12 +27,31 @@ export function UnsubscribeBanner({
const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const isDesktop = useIsDesktop();
const unsubMethod = listUnsubscribe.preferred;
const unsubUrl = unsubMethod === 'http'
? listUnsubscribe.http
: listUnsubscribe.mailto;
useEffect(() => {
if (!showConfirm) return;
const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setShowConfirm(false);
}
};
// Use setTimeout to avoid the opening click triggering immediate close
const timer = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showConfirm]);
if (!unsubUrl || !unsubMethod) {
return null;
}
@@ -39,6 +60,7 @@ export function UnsubscribeBanner({
if (!isValidUnsubscribeUrl(unsubUrl)) {
setError(true);
setProcessing(false);
setShowConfirm(false);
return;
}
@@ -49,6 +71,7 @@ export function UnsubscribeBanner({
window.open(unsubUrl, '_blank', 'noopener,noreferrer');
setSuccess(true);
setProcessing(false);
setShowConfirm(false);
setTimeout(onDismiss, 3000);
} else {
const link = document.createElement('a');
@@ -60,76 +83,102 @@ export function UnsubscribeBanner({
setSuccess(true);
setProcessing(false);
setShowConfirm(false);
setTimeout(onDismiss, 3000);
}
} catch (err) {
console.error('Unsubscribe error:', err);
setError(true);
setProcessing(false);
setShowConfirm(false);
}
};
if (success) {
return (
<div className="flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1 ml-1">
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-400" />
<span className="text-xs text-green-600 dark:text-green-400">
{t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
)}
</span>
</div>
</span>
);
}
if (error) {
return (
<div className="flex items-center gap-2 flex-wrap">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{t('email_viewer.unsubscribe_banner.error')}
</span>
<span className="inline-flex items-center gap-1 ml-1">
<AlertCircle className="w-3 h-3 text-red-500 dark:text-red-400" />
<button
onClick={onDismiss}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
className="text-xs text-red-500 dark:text-red-400 hover:underline bg-transparent p-0 min-h-0"
>
{t('email_viewer.unsubscribe_banner.dismiss')}
{t('email_viewer.unsubscribe_banner.error')}
</button>
</div>
</span>
);
}
return (
<div className="flex items-center gap-2 flex-wrap">
{showConfirm ? (
<>
<span className="text-sm text-muted-foreground">
{t('email_viewer.unsubscribe_banner.confirm_title')}
</span>
<button
onClick={handleUnsubscribe}
disabled={processing}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] md:min-h-0"
>
{processing && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{t('email_viewer.unsubscribe_banner.confirm_button')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.cancel')}
</button>
</>
) : (
<>
<span className="relative inline-flex items-center">
<span className="text-muted-foreground/40 mx-1">·</span>
<button
onClick={() => setShowConfirm(true)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
className="text-xs text-blue-600 dark:text-blue-400 hover:underline bg-transparent p-0 min-h-0 leading-normal"
>
{t('email_viewer.unsubscribe_banner.button')}
</button>
{/* Desktop popover */}
{showConfirm && isDesktop && (
<div
ref={popoverRef}
className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
>
<p className="text-sm text-foreground mb-2">
{t('email_viewer.unsubscribe_banner.confirm_title')}
</p>
<div className="flex items-center gap-2">
<button
onClick={handleUnsubscribe}
disabled={processing}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{processing && <Loader2 className="w-3 h-3 animate-spin" />}
{t('email_viewer.unsubscribe_banner.confirm_button')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-xs px-3 py-1.5 rounded-md text-muted-foreground hover:bg-muted transition-colors"
>
{t('email_viewer.unsubscribe_banner.cancel')}
</button>
</div>
</div>
)}
</span>
{/* Mobile/tablet: proper confirm dialog */}
{!isDesktop && (
<ConfirmDialog
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={() => {
setShowConfirm(false);
handleUnsubscribe();
}}
title={t('email_viewer.unsubscribe_banner.confirm_title')}
message={t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
)}
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
cancelText={t('email_viewer.unsubscribe_banner.cancel')}
variant="destructive"
/>
)}
</div>
</>
);
}
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getFilePreviewKind } from "@/lib/file-preview";
interface FilePreviewModalProps {
name: string;
onClose: () => void;
onDownload: () => Promise<void> | void;
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
}
function SimpleMarkdown({ content }: { content: string }) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Headers
if (line.startsWith("### ")) {
elements.push(<h3 key={i} className="text-lg font-semibold mt-4 mb-2">{processInline(line.slice(4))}</h3>);
} else if (line.startsWith("## ")) {
elements.push(<h2 key={i} className="text-xl font-semibold mt-5 mb-2">{processInline(line.slice(3))}</h2>);
} else if (line.startsWith("# ")) {
elements.push(<h1 key={i} className="text-2xl font-bold mt-6 mb-3">{processInline(line.slice(2))}</h1>);
} else if (line.startsWith("---") || line.startsWith("***")) {
elements.push(<hr key={i} className="my-4 border-border" />);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
elements.push(<li key={i} className="ml-4 list-disc">{processInline(line.slice(2))}</li>);
} else if (/^\d+\. /.test(line)) {
elements.push(<li key={i} className="ml-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
} else if (line.startsWith("> ")) {
elements.push(<blockquote key={i} className="border-l-4 border-border pl-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
} else if (line.startsWith("```")) {
// Code block - collect until closing ```
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
elements.push(
<pre key={i} className="bg-muted rounded p-3 my-2 overflow-x-auto text-sm font-mono">
<code>{codeLines.join("\n")}</code>
</pre>
);
} else if (line.trim() === "") {
elements.push(<div key={i} className="h-2" />);
} else {
elements.push(<p key={i} className="my-1">{processInline(line)}</p>);
}
}
return <div className="prose prose-sm dark:prose-invert max-w-none">{elements}</div>;
}
function processInline(text: string): React.ReactNode {
// Process bold, italic, code inline
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Bold
const boldMatch = remaining.match(/\*\*(.+?)\*\*/);
// Inline code
const codeMatch = remaining.match(/`([^`]+)`/);
// Italic
const italicMatch = remaining.match(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/);
const matches = [
boldMatch && { type: "bold", match: boldMatch },
codeMatch && { type: "code", match: codeMatch },
italicMatch && { type: "italic", match: italicMatch },
].filter(Boolean).sort((a, b) => (a!.match.index ?? 0) - (b!.match.index ?? 0));
if (matches.length === 0) {
parts.push(remaining);
break;
}
const first = matches[0]!;
const idx = first.match.index ?? 0;
if (idx > 0) {
parts.push(remaining.slice(0, idx));
}
if (first.type === "bold") {
parts.push(<strong key={key++}>{first.match[1]}</strong>);
} else if (first.type === "code") {
parts.push(<code key={key++} className="bg-muted px-1 py-0.5 rounded text-sm font-mono">{first.match[1]}</code>);
} else {
parts.push(<em key={key++}>{first.match[1]}</em>);
}
remaining = remaining.slice(idx + first.match[0].length);
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) {
const t = useTranslations("files");
const [content, setContent] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name));
const fileType = resolvedFileType;
useEffect(() => {
let cancelled = false;
let revokeUrl: string | null = null;
setContent(null);
setObjectUrl(null);
setLoading(true);
setError(false);
setResolvedFileType(getFilePreviewKind(name));
async function load() {
try {
const { blob, contentType } = await getFileContent();
if (cancelled) return;
const previewType = getFilePreviewKind(name, contentType || blob.type);
setResolvedFileType(previewType);
if (previewType === "text" || previewType === "markdown") {
const text = await blob.text();
if (!cancelled) setContent(text);
} else {
revokeUrl = URL.createObjectURL(blob);
if (!cancelled) setObjectUrl(revokeUrl);
}
} catch {
if (!cancelled) setError(true);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
if (revokeUrl) {
URL.revokeObjectURL(revokeUrl);
}
};
}, [getFileContent, name]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
)}
{error && (
<p className="text-sm text-destructive">{t("preview_error")}</p>
)}
{!loading && !error && (fileType === "text") && content !== null && (
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
{content}
</pre>
)}
{!loading && !error && fileType === "markdown" && content !== null && (
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
<SimpleMarkdown content={content} />
</div>
)}
{!loading && !error && fileType === "image" && objectUrl && (
<img
src={objectUrl}
alt={name}
className="max-w-full max-h-full object-contain rounded-lg bg-background"
draggable={false}
/>
)}
{!loading && !error && fileType === "html" && objectUrl && (
<iframe
src={objectUrl}
sandbox=""
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
)}
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
)}
{!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
<p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} />
</div>
)}
{!loading && !error && fileType === "video" && objectUrl && (
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
)}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { Upload, FolderPlus, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FileUploadAreaProps {
onUpload: (files: File[]) => Promise<void>;
onCreateFolder: () => void;
onCreateTextFile?: () => void;
}
export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) {
const t = useTranslations("files");
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await onUpload(files);
}
}, [onUpload]);
return (
<div className="flex items-center justify-center h-full p-8">
<div
className={`flex flex-col items-center gap-4 p-12 rounded-xl border-2 border-dashed transition-colors max-w-md w-full ${
isDragging ? "border-primary bg-primary/5" : "border-border"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
<Upload className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center">
<h3 className="text-base font-medium">{t("empty_state_title")}</h3>
<p className="text-sm text-muted-foreground mt-1">{t("empty_state_description")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("drop_files_here")}</p>
</div>
<div className="flex gap-2 flex-wrap justify-center">
<Button
variant="outline"
size="sm"
onClick={onCreateFolder}
>
<FolderPlus className="w-4 h-4 mr-2" />
{t("new_folder")}
</Button>
{onCreateTextFile && (
<Button
variant="outline"
size="sm"
onClick={onCreateTextFile}
>
<FilePlus className="w-4 h-4 mr-2" />
{t("new_text_file")}
</Button>
)}
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "@/components/settings/settings-section";
export type FolderLayout = "inline" | "sidebar";
export interface FilesSettings {
defaultViewMode: "list" | "grid";
showIcons: boolean;
coloredIcons: boolean;
defaultSortKey: "name" | "size" | "modified";
defaultSortDir: "asc" | "desc";
showHiddenFiles: boolean;
showThumbnails: boolean;
folderLayout: FolderLayout;
}
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
defaultViewMode: "list",
showIcons: true,
coloredIcons: true,
defaultSortKey: "name",
defaultSortDir: "asc",
showHiddenFiles: false,
showThumbnails: true,
folderLayout: "inline",
};
export function loadFilesSettings(): FilesSettings {
if (typeof window === "undefined") return DEFAULT_FILES_SETTINGS;
try {
const raw = localStorage.getItem("files-settings");
if (raw) return { ...DEFAULT_FILES_SETTINGS, ...JSON.parse(raw) };
} catch { /* ignore */ }
return DEFAULT_FILES_SETTINGS;
}
export function saveFilesSettings(settings: FilesSettings) {
localStorage.setItem("files-settings", JSON.stringify(settings));
// Dispatch custom event for same-tab listeners (StorageEvent only fires cross-tab)
window.dispatchEvent(new CustomEvent("files-settings-changed"));
}
interface FilesSettingsDialogProps {
isOpen: boolean;
onClose: () => void;
settings: FilesSettings;
onSettingsChange: (settings: FilesSettings) => void;
}
export function FilesSettingsDialog({ isOpen, onClose, settings, onSettingsChange }: FilesSettingsDialogProps) {
const t = useTranslations("files");
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (isOpen) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen) return null;
const update = (patch: Partial<FilesSettings>) => {
const next = { ...settings, ...patch };
onSettingsChange(next);
saveFilesSettings(next);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("settings_title")}
className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[80vh] flex flex-col"
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("settings_title")}</h2>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="overflow-y-auto p-4 space-y-6">
<SettingsSection title={t("settings_display")}>
<SettingItem label={t("settings_folder_layout")} description={t("settings_folder_layout_desc")}>
<RadioGroup
value={settings.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
options={[
{ value: "inline", label: t("settings_folder_layout_inline") },
{ value: "sidebar", label: t("settings_folder_layout_sidebar") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_default_view")} description={t("settings_default_view_desc")}>
<RadioGroup
value={settings.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
options={[
{ value: "list", label: t("list_view") },
{ value: "grid", label: t("grid_view") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_default_sort")} description={t("settings_default_sort_desc")}>
<RadioGroup
value={settings.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
options={[
{ value: "name", label: t("name") },
{ value: "size", label: t("size") },
{ value: "modified", label: t("modified") },
]}
/>
</SettingItem>
<SettingItem label={t("settings_sort_direction")} description={t("settings_sort_direction_desc")}>
<RadioGroup
value={settings.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
options={[
{ value: "asc", label: t("settings_ascending") },
{ value: "desc", label: t("settings_descending") },
]}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("settings_icons")}>
<SettingItem label={t("settings_show_icons")} description={t("settings_show_icons_desc")}>
<ToggleSwitch
checked={settings.showIcons}
onChange={(v) => update({ showIcons: v })}
/>
</SettingItem>
<SettingItem label={t("settings_colored_icons")} description={t("settings_colored_icons_desc")}>
<ToggleSwitch
checked={settings.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!settings.showIcons}
/>
</SettingItem>
<SettingItem label={t("settings_show_thumbnails")} description={t("settings_show_thumbnails_desc")}>
<ToggleSwitch
checked={settings.showThumbnails}
onChange={(v) => update({ showThumbnails: v })}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("settings_behavior")}>
<SettingItem label={t("settings_show_hidden")} description={t("settings_show_hidden_desc")}>
<ToggleSwitch
checked={settings.showHiddenFiles}
onChange={(v) => update({ showHiddenFiles: v })}
/>
</SettingItem>
</SettingsSection>
</div>
</div>
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import {
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Home,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFileStore, type FileResource } from "@/stores/file-store";
interface FolderNode {
id: string;
name: string;
path: string;
}
interface FolderTreeSidebarProps {
currentPath: string;
onNavigate: (path: string, resourceId?: string | null) => void;
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
width?: number;
isResizing?: boolean;
}
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
const t = useTranslations("files");
const client = useFileStore(s => s.client);
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
// Cache: parentId (or "root") -> FolderNode[]
const [childrenCache, setChildrenCache] = useState<Map<string, FolderNode[]>>(new Map());
// Map folder path -> id for reverse lookup
const pathToIdRef = useRef<Map<string, string>>(new Map());
const loadChildren = useCallback(async (parentId: string | null, parentPath: string) => {
const cacheKey = parentId ?? "root";
// Skip if already loading or cached
if (childrenCache.has(cacheKey)) return;
setLoadingIds(prev => new Set(prev).add(cacheKey));
try {
const resources = await listByParentId(parentId);
const folders = resources
.filter(r => r.isDirectory)
.sort((a, b) => a.name.localeCompare(b.name))
.map(r => {
const folderPath = parentPath === "/" ? `/${r.name}` : `${parentPath}/${r.name}`;
pathToIdRef.current.set(folderPath, r.id);
return {
id: r.id,
name: r.name,
path: folderPath,
};
});
// Don't cache empty root results — empty root likely means client wasn't ready yet
if (folders.length > 0 || parentId !== null) {
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
}
if (parentId === null) {
setRootChildren(folders);
}
} catch {
// Silently fail
} finally {
setLoadingIds(prev => {
const next = new Set(prev);
next.delete(cacheKey);
return next;
});
}
}, [childrenCache, listByParentId]);
// Load root folders when client is available (handles page refresh timing)
useEffect(() => {
if (client) {
loadChildren(null, "/");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client]);
// Auto-expand along the current path when navigating
useEffect(() => {
if (currentPath === "/") return;
const segments = currentPath.split("/").filter(Boolean);
// Walk down the path and expand + load each ancestor
let ancestorPath = "";
for (let i = 0; i < segments.length; i++) {
ancestorPath = "/" + segments.slice(0, i + 1).join("/");
const folderId = pathToIdRef.current.get(ancestorPath);
if (folderId) {
setExpandedIds(prev => {
if (prev.has(folderId)) return prev;
return new Set(prev).add(folderId);
});
if (!childrenCache.has(folderId)) {
loadChildren(folderId, ancestorPath);
}
}
}
}, [currentPath, childrenCache, loadChildren]);
const handleToggleExpand = useCallback(async (folderId: string, folderPath: string) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
if (!childrenCache.has(folderId)) {
await loadChildren(folderId, folderPath);
}
}, [childrenCache, loadChildren]);
const handleFolderClick = useCallback((path: string, id: string | null) => {
onNavigate(path, id);
}, [onNavigate]);
return (
<div
className={cn(
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${width}px` }}
>
<div className="flex-1 overflow-y-auto py-1">
{/* Root / Home entry */}
<div
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
currentPath === "/"
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
"font-medium"
)}
>
<button
onClick={() => handleFolderClick("/", null)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
>
<Home className={cn("w-4 h-4 flex-shrink-0 mr-2 transition-colors")} />
<span className="truncate">{t("breadcrumb_root")}</span>
</button>
</div>
{/* Folder tree */}
{rootChildren === null && loadingIds.has("root") ? (
<div className="px-3 py-2 space-y-2">
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
<div className="h-4 w-28 bg-muted animate-pulse rounded" />
</div>
) : (
rootChildren?.map(folder => (
<FolderTreeItem
key={folder.id}
node={folder}
depth={0}
currentPath={currentPath}
expandedIds={expandedIds}
loadingIds={loadingIds}
childrenCache={childrenCache}
onToggleExpand={handleToggleExpand}
onFolderClick={handleFolderClick}
onLoadChildren={loadChildren}
/>
))
)}
</div>
</div>
);
}
function FolderTreeItem({
node,
depth,
currentPath,
expandedIds,
loadingIds,
childrenCache,
onToggleExpand,
onFolderClick,
onLoadChildren,
}: {
node: FolderNode;
depth: number;
currentPath: string;
expandedIds: Set<string>;
loadingIds: Set<string>;
childrenCache: Map<string, FolderNode[]>;
onToggleExpand: (folderId: string, folderPath: string) => void;
onFolderClick: (path: string, id: string | null) => void;
onLoadChildren: (parentId: string, parentPath: string) => Promise<void>;
}) {
const isExpanded = expandedIds.has(node.id);
const isSelected = currentPath === node.path;
const isLoading = loadingIds.has(node.id);
const children = childrenCache.get(node.id);
const hasChildren = children !== undefined && children.length > 0;
const indentPx = depth * 16;
const Icon = isExpanded && hasChildren ? FolderOpen : Folder;
// Eagerly load children on mount to know if subfolders exist
useEffect(() => {
if (children === undefined && !loadingIds.has(node.id)) {
onLoadChildren(node.id, node.path);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
isSelected
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
depth === 0 && "font-medium"
)}
>
{/* Expand/collapse chevron */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id, node.path);
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
style={{ marginLeft: `${indentPx}px` }}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
) : null}
{/* Folder name */}
<button
onClick={() => onFolderClick(node.path, node.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
style={{
paddingBlock: "var(--density-sidebar-py)",
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
}}
>
<Icon className={cn(
"w-4 h-4 flex-shrink-0 mr-2 transition-colors",
isExpanded && hasChildren && "text-primary",
!hasChildren && depth > 0 && "text-muted-foreground"
)} />
<span className="truncate">{node.name}</span>
</button>
</div>
{/* Children */}
{isExpanded && children && (
<div className="relative">
{children.map(child => (
<FolderTreeItem
key={child.id}
node={child}
depth={depth + 1}
currentPath={currentPath}
expandedIds={expandedIds}
loadingIds={loadingIds}
childrenCache={childrenCache}
onToggleExpand={onToggleExpand}
onFolderClick={onFolderClick}
onLoadChildren={onLoadChildren}
/>
))}
</div>
)}
</>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { X, Download, ZoomIn, ZoomOut, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "next-intl";
interface ImagePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
}
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: ImagePreviewModalProps) {
const t = useTranslations("files");
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
useEffect(() => {
let revoke: string | null = null;
setLoading(true);
setError(false);
getImageUrl(name)
.then((url) => {
revoke = url;
setImageUrl(url);
setLoading(false);
})
.catch(() => {
setError(true);
setLoading(false);
});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [name, getImageUrl]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5));
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25));
if (e.key === "r") setRotation((r) => r + 90);
}, [onClose]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div
role="dialog"
aria-label={name}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
onClick={onClose}
>
{/* Header */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/60 to-transparent z-10">
<span className="text-white text-sm font-medium truncate max-w-[50%]">{name}</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.min(z + 0.25, 5)); }}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.max(z - 0.25, 0.25)); }}>
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setRotation((r) => r + 90); }}>
<RotateCw className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); onDownload(name); }}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Image */}
<div className="flex items-center justify-center w-full h-full p-16" onClick={(e) => e.stopPropagation()}>
{loading && (
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
{error && (
<p className="text-white/70 text-sm">{t("preview_error")}</p>
)}
{imageUrl && !error && (
<img
src={imageUrl}
alt={name}
className="max-w-full max-h-full object-contain transition-transform duration-200"
style={{ transform: `scale(${zoom}) rotate(${rotation}deg)` }}
onLoad={() => setLoading(false)}
draggable={false}
/>
)}
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface NewFolderDialogProps {
onConfirm: (name: string) => Promise<void>;
onCancel: () => void;
}
export function NewFolderDialog({ onConfirm, onCancel }: NewFolderDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{t("new_folder")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("new_folder_name")}
className="mb-4"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
{t("create")}
</Button>
</div>
</form>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface RenameDialogProps {
currentName: string;
title?: string;
label?: string;
onConfirm: (newName: string) => Promise<void>;
onCancel: () => void;
}
export function RenameDialog({ currentName, title, label, onConfirm, onCancel }: RenameDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState(currentName);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
await onConfirm(trimmed);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4">{title || t("rename_title")}</h2>
<form onSubmit={handleSubmit}>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={label || t("new_name")}
className="mb-4"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
{t("save")}
</Button>
</div>
</form>
</div>
</div>
);
}
+11 -11
View File
@@ -156,32 +156,32 @@ export function FilterRuleModal({
};
const selectClass =
"px-2 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer";
"px-2.5 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={isEdit ? t("edit_rule") : t("new_rule")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto"
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? t("edit_rule") : t("new_rule")}
</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={t("cancel")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 space-y-6">
<div className="px-6 py-4 space-y-6">
<div>
<label className="text-sm font-medium mb-1 block text-foreground">
{t("rule_name")}
@@ -203,9 +203,9 @@ export function FilterRuleModal({
<button
type="button"
onClick={() => setMatchType("all")}
className={`px-3 py-1.5 text-xs rounded transition-colors ${
className={`px-3 py-1.5 text-xs rounded-md transition-colors duration-150 ${
matchType === "all"
? "bg-primary text-primary-foreground"
? "bg-primary text-primary-foreground font-medium"
: "bg-muted hover:bg-accent text-foreground"
}`}
>
@@ -214,9 +214,9 @@ export function FilterRuleModal({
<button
type="button"
onClick={() => setMatchType("any")}
className={`px-3 py-1.5 text-xs rounded transition-colors ${
className={`px-3 py-1.5 text-xs rounded-md transition-colors duration-150 ${
matchType === "any"
? "bg-primary text-primary-foreground"
? "bg-primary text-primary-foreground font-medium"
: "bg-muted hover:bg-accent text-foreground"
}`}
>
@@ -409,7 +409,7 @@ export function FilterRuleModal({
</div>
</div>
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border">
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
+6 -6
View File
@@ -73,26 +73,26 @@ export function SieveEditorModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col"
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={t("cancel")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 flex-1 overflow-hidden flex flex-col space-y-4">
<div className="px-6 py-4 flex-1 overflow-hidden flex flex-col space-y-4">
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>{t("warning")}</p>
@@ -164,7 +164,7 @@ export function SieveEditorModal({
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
<Button
variant="outline"
onClick={handleValidate}
+11 -2
View File
@@ -9,6 +9,11 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
function useSyncIdentities() {
const syncIdentities = useAuthStore((state) => state.syncIdentities);
return syncIdentities;
}
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -34,6 +39,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const client = useAuthStore((state) => state.client);
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
const syncIdentities = useSyncIdentities();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
@@ -82,6 +88,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
);
addIdentity(newIdentity);
syncIdentities();
setIsCreating(false);
toast.success(tNotif('identity_created'));
} catch (error) {
@@ -104,6 +111,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
});
updateIdentityLocal(identity.id, data);
syncIdentities();
setEditingId(null);
toast.success(tNotif('identity_updated'));
} catch (error) {
@@ -133,6 +141,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
try {
await client.deleteIdentity(identity.id);
removeIdentity(identity.id);
syncIdentities();
toast.success(tNotif('identity_deleted'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
@@ -145,7 +154,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
role="dialog"
@@ -167,7 +176,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
</div>
<button
onClick={onClose}
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
+2 -2
View File
@@ -24,7 +24,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
@@ -48,7 +48,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
</div>
<button
onClick={onClose}
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={t("common.close")}
>
<X className="w-5 h-5" />
@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { ResizeHandle } from '../resize-handle';
describe('ResizeHandle', () => {
it('should render with separator role', () => {
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} />
);
const handle = getByRole('separator');
expect(handle).toBeInTheDocument();
expect(handle).toHaveAttribute('aria-orientation', 'vertical');
expect(handle).toHaveAttribute('tabindex', '0');
});
describe('keyboard interaction', () => {
it('should call onResize with negative delta on ArrowLeft', () => {
const onResize = vi.fn();
const onResizeEnd = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} onResizeEnd={onResizeEnd} />
);
const handle = getByRole('separator');
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
expect(onResize).toHaveBeenCalledWith(-10);
expect(onResizeEnd).toHaveBeenCalled();
});
it('should call onResize with positive delta on ArrowRight', () => {
const onResize = vi.fn();
const onResizeEnd = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} onResizeEnd={onResizeEnd} />
);
const handle = getByRole('separator');
fireEvent.keyDown(handle, { key: 'ArrowRight' });
expect(onResize).toHaveBeenCalledWith(10);
expect(onResizeEnd).toHaveBeenCalled();
});
it('should not respond to other keys', () => {
const onResize = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} />
);
const handle = getByRole('separator');
fireEvent.keyDown(handle, { key: 'ArrowUp' });
fireEvent.keyDown(handle, { key: 'Enter' });
fireEvent.keyDown(handle, { key: 'a' });
expect(onResize).not.toHaveBeenCalled();
});
});
describe('double-click', () => {
it('should call onDoubleClick when provided', () => {
const onDoubleClick = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} onDoubleClick={onDoubleClick} />
);
const handle = getByRole('separator');
fireEvent.doubleClick(handle);
expect(onDoubleClick).toHaveBeenCalledOnce();
});
it('should not error when onDoubleClick is not provided', () => {
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} />
);
const handle = getByRole('separator');
expect(() => fireEvent.doubleClick(handle)).not.toThrow();
});
});
describe('mouse drag', () => {
it('should call onResize during mousemove after mousedown', () => {
const onResize = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} />
);
const handle = getByRole('separator');
fireEvent.mouseDown(handle, { clientX: 100 });
fireEvent.mouseMove(document, { clientX: 115 });
expect(onResize).toHaveBeenCalledWith(15);
});
it('should call onResizeEnd on mouseup', () => {
const onResizeEnd = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} onResizeEnd={onResizeEnd} />
);
const handle = getByRole('separator');
fireEvent.mouseDown(handle, { clientX: 100 });
fireEvent.mouseUp(document);
expect(onResizeEnd).toHaveBeenCalledOnce();
});
it('should not call onResize on mousemove without mousedown', () => {
const onResize = vi.fn();
render(<ResizeHandle onResize={onResize} />);
fireEvent.mouseMove(document, { clientX: 200 });
expect(onResize).not.toHaveBeenCalled();
});
});
});
+206 -41
View File
@@ -1,11 +1,14 @@
"use client";
import { Mail, Calendar, BookUser, Settings } from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { cn } from "@/lib/utils";
import { useWebDAVStore } from "@/stores/webdav-store";
import { cn, formatFileSize } from "@/lib/utils";
interface NavItem {
id: string;
@@ -20,23 +23,134 @@ interface NavigationRailProps {
orientation?: "vertical" | "horizontal";
collapsed?: boolean;
className?: string;
quota?: { used: number; total: number } | null;
isPushConnected?: boolean;
onLogout?: () => void;
onShowShortcuts?: () => void;
}
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
const t = useTranslations("sidebar");
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
setPopoverStyle({
position: "fixed",
left: rect.right + 8,
bottom: window.innerHeight - rect.bottom,
});
}, []);
useEffect(() => {
if (!open) return;
updatePosition();
const handleClick = (e: MouseEvent) => {
if (
buttonRef.current?.contains(e.target as Node) ||
popoverRef.current?.contains(e.target as Node)
) return;
setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open, updatePosition]);
const free = quota.total - quota.used;
const strokeColor = usagePercent > 90
? "stroke-red-500 dark:stroke-red-400"
: usagePercent > 70
? "stroke-amber-500 dark:stroke-amber-400"
: "stroke-green-500 dark:stroke-green-400";
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
className="relative w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors cursor-pointer"
aria-label={t("storage")}
>
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="12" fill="none" className="stroke-muted" strokeWidth="3" />
<circle
cx="16" cy="16" r="12" fill="none"
className={cn(strokeColor)}
strokeWidth="3" strokeLinecap="round"
strokeDasharray={`${(usagePercent / 100) * 75.4} 75.4`}
style={{ transition: "stroke-dasharray 0.3s" }}
/>
</svg>
<span className="absolute text-[7px] font-bold text-muted-foreground tabular-nums">
{Math.round(usagePercent)}%
</span>
</button>
{open && createPortal(
<div ref={popoverRef} style={popoverStyle} className="w-52 rounded-lg border border-border bg-background text-foreground shadow-lg p-3 z-50">
<p className="text-xs font-semibold mb-2">{t("storage")}</p>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_used")}</span>
<span className="font-medium tabular-nums">{formatFileSize(quota.used)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_free")}</span>
<span className="font-medium tabular-nums">{formatFileSize(free)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_total")}</span>
<span className="font-medium tabular-nums">{formatFileSize(quota.total)}</span>
</div>
</div>
<div className="mt-2.5 w-full bg-muted rounded-full h-1.5">
<div
className={cn(
"h-1.5 rounded-full transition-all",
usagePercent > 90
? "bg-red-500 dark:bg-red-400"
: usagePercent > 70
? "bg-amber-500 dark:bg-amber-400"
: "bg-green-500 dark:bg-green-400"
)}
style={{ width: `${usagePercent}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground mt-1 tabular-nums">
{Math.round(usagePercent)}% {t("storage_used").toLowerCase()}
</p>
</div>,
document.body
)}
</div>
);
}
export function NavigationRail({
orientation = "vertical",
collapsed = false,
className,
quota,
isPushConnected,
onLogout,
onShowShortcuts,
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
{ id: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
];
@@ -52,7 +166,7 @@ export function NavigationRail({
if (orientation === "horizontal") {
return (
<nav
className={cn("flex items-center justify-around bg-background border-t border-border", className)}
className={cn("flex items-center justify-around bg-background border-t border-border shrink-0", className)}
role="navigation"
aria-label={t("nav_label")}
>
@@ -91,49 +205,100 @@ export function NavigationRail({
);
}
const quotaUsagePercent = quota && quota.total > 0 ? Math.min((quota.used / quota.total) * 100, 100) : 0;
return (
<nav
<div
className={cn(
"flex flex-col",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
"flex flex-col h-full",
collapsed ? "items-center" : "",
className
)}
role="navigation"
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 py-1.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
title={collapsed ? t(item.labelKey) : undefined}
<nav
className={cn(
"flex flex-col",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
)}
role="navigation"
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
title={collapsed ? t(item.labelKey) : undefined}
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{t(item.labelKey)}</span>}
{item.badge != null && item.badge > 0 && (
<span className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1",
collapsed ? "-top-0.5 -right-0.5" : "right-1.5"
)}>
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</Link>
);
})}
</nav>
{/* Footer: Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 border-t border-border pt-2">
{quota && quota.total > 0 && (
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
)}
{onShowShortcuts && (
<button
onClick={onShowShortcuts}
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{t(item.labelKey)}</span>}
{item.badge != null && item.badge > 0 && (
<span className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1",
collapsed ? "-top-0.5 -right-0.5" : "right-1.5"
)}>
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</Link>
);
})}
</nav>
<Keyboard className="w-[18px] h-[18px]" />
</button>
)}
{isPushConnected != null && (
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
</span>
)}
{onLogout && (
<button
onClick={onLogout}
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("sign_out")}
>
<LogOut className="w-[18px] h-[18px]" />
</button>
)}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ResizeHandleProps {
onResizeStart?: () => void;
onResize: (delta: number) => void;
onResizeEnd?: () => void;
onDoubleClick?: () => void;
className?: string;
}
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
const isDragging = useRef(false);
const startX = useRef(0);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
onResizeStart?.();
}, [onResizeStart]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let delta = 0;
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
else return;
e.preventDefault();
onResize(delta);
onResizeEnd?.();
}, [onResize, onResizeEnd]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const delta = e.clientX - startX.current;
onResize(delta);
};
const handleMouseUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
onResizeEnd?.();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [onResize, onResizeEnd]);
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize"
tabIndex={0}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick}
className={cn(
"w-1 flex-shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
className
)}
>
<div className="absolute inset-y-0 -left-1 -right-1" />
</div>
);
}
+260 -199
View File
@@ -4,7 +4,6 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Inbox,
Send,
@@ -13,9 +12,8 @@ import {
Trash2,
Archive,
PenSquare,
Search,
Menu,
LogOut,
ChevronsLeft,
ChevronsRight,
ChevronRight,
ChevronDown,
Folder,
@@ -23,32 +21,32 @@ import {
Users,
User,
Palmtree,
SlidersHorizontal,
Settings,
X,
Tag,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { useEmailStore } from "@/stores/email-store";
import { activeFilterCount } from "@/lib/jmap/search-utils";
import { useTagDrop } from "@/hooks/use-tag-drop";
import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
interface SidebarProps {
mailboxes: Mailbox[];
selectedMailbox?: string;
selectedKeyword?: string | null;
onMailboxSelect?: (mailboxId: string) => void;
onTagSelect?: (keywordId: string | null) => void;
onCompose?: () => void;
onLogout?: () => void;
onSidebarClose?: () => void;
onSearch?: (query: string) => void;
onClearSearch?: () => void;
activeSearchQuery?: string;
quota?: { used: number; total: number } | null;
isPushConnected?: boolean;
onUnreadFilterClick?: (mailboxId: string) => void;
className?: string;
}
@@ -87,6 +85,7 @@ function MailboxTreeItem({
onMailboxSelect,
onToggleExpand,
isCollapsed,
onUnreadFilterClick,
}: {
node: MailboxNode;
selectedMailbox: string;
@@ -94,6 +93,7 @@ function MailboxTreeItem({
onMailboxSelect?: (id: string) => void;
onToggleExpand: (id: string) => void;
isCollapsed: boolean;
onUnreadFilterClick?: (mailboxId: string) => void;
}) {
const t = useTranslations('sidebar');
const tNotifications = useTranslations('notifications');
@@ -128,8 +128,10 @@ function MailboxTreeItem({
<>
<div
{...(globalDragging ? dropHandlers : {})}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center px-2 py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200",
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200",
isCollapsed ? "justify-center px-1" : "px-2",
isVirtualNode
? "text-muted-foreground"
: selectedMailbox === node.id
@@ -140,7 +142,7 @@ function MailboxTreeItem({
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
)}
>
{hasChildren && (
{hasChildren && !isCollapsed && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -165,17 +167,20 @@ function MailboxTreeItem({
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
disabled={isVirtualNode}
className={cn(
"flex-1 flex items-center text-left py-1 lg:py-1 max-lg:py-2 px-1 rounded",
"flex items-center px-1 rounded",
"transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left",
isVirtualNode && "cursor-default select-none"
)}
style={{
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
paddingBlock: 'var(--density-sidebar-py)',
...(isCollapsed ? {} : { paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px` })
}}
title={isCollapsed ? node.name : undefined}
>
<Icon className={cn(
"w-4 h-4 mr-2 flex-shrink-0 transition-colors",
"w-4 h-4 flex-shrink-0 transition-colors",
!isCollapsed && "mr-2",
hasChildren && isExpanded && "text-primary",
selectedMailbox === node.id && "text-accent-foreground",
!hasChildren && node.depth > 0 && "text-muted-foreground",
@@ -184,16 +189,37 @@ function MailboxTreeItem({
{!isCollapsed && (
<>
<span className="flex-1 truncate">{node.name}</span>
{node.unreadEmails > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 ml-2 font-medium",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
{node.unreadEmails}
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{node.unreadEmails > 0 && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onUnreadFilterClick?.(node.id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onUnreadFilterClick?.(node.id);
}
}}
className={cn(
"text-xs rounded-full px-2 py-0.5 font-medium cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}
title={node.unreadEmails + " unread"}
>
{node.unreadEmails}
</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">
{node.totalEmails}
</span>
)}
</span>
</>
)}
</button>
@@ -210,6 +236,7 @@ function MailboxTreeItem({
onMailboxSelect={onMailboxSelect}
onToggleExpand={onToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</div>
@@ -218,6 +245,86 @@ function MailboxTreeItem({
);
}
function TagItem({
kw,
isSelected,
isCollapsed,
onTagSelect,
totalCount,
unreadCount,
}: {
kw: KeywordDefinition;
isSelected: boolean;
isCollapsed: boolean;
onTagSelect?: (keywordId: string | null) => void;
totalCount: number;
unreadCount: number;
}) {
const t = useTranslations('notifications');
const palette = KEYWORD_PALETTE[kw.color];
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget } = useTagDrop({
tagId: kw.id,
onSuccess: (count, _tagLabel) => {
if (count === 1) {
toast.success(t('email_tagged'), kw.label);
} else {
toast.success(t('emails_tagged', { count }), kw.label);
}
},
onError: () => {
toast.error(t('tag_failed'), kw.label);
},
});
return (
<div
{...(globalDragging ? dropHandlers : {})}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200",
isCollapsed ? "justify-center px-1" : "px-2",
isSelected
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset"
)}
>
<button
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
className={cn(
"flex items-center px-1 rounded transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left"
)}
style={{ paddingBlock: 'var(--density-sidebar-py)', ...(isCollapsed ? {} : { paddingLeft: '40px' }) }}
title={isCollapsed ? kw.label : undefined}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
{!isCollapsed && (
<>
<span className="truncate">{kw.label}</span>
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{unreadCount > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 font-medium",
isSelected
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
{unreadCount}
</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">
{totalCount}
</span>
</span>
</>
)}
</button>
</div>
);
}
function VacationBanner() {
const t = useTranslations('sidebar');
const router = useRouter();
@@ -241,93 +348,30 @@ function VacationBanner() {
);
}
function AdvancedSearchToggle() {
const tSearch = useTranslations("advanced_search");
const { searchFilters, isAdvancedSearchOpen, toggleAdvancedSearch } = useEmailStore();
const filterCount = activeFilterCount(searchFilters);
return (
<button
type="button"
onClick={toggleAdvancedSearch}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
isAdvancedSearchOpen || filterCount > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={tSearch("toggle_filters")}
>
<SlidersHorizontal className="w-4 h-4" />
{filterCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{filterCount}
</span>
)}
</button>
);
}
function StorageQuota({ quota, isCollapsed }: { quota: { used: number; total: number } | null; isCollapsed: boolean }) {
const t = useTranslations('sidebar');
if (!quota || quota.total <= 0) return null;
const usagePercent = Math.min((quota.used / quota.total) * 100, 100);
const barColor = usagePercent > 90
? "bg-red-500 dark:bg-red-400"
: usagePercent > 70
? "bg-amber-500 dark:bg-amber-400"
: "bg-green-500 dark:bg-green-400";
if (isCollapsed) {
return (
<div className="px-2 py-2" title={`${formatFileSize(quota.used)} / ${formatFileSize(quota.total)}`}>
<div className="w-full bg-muted rounded-full h-1">
<div className={cn(barColor, "h-1 rounded-full transition-all")} style={{ width: `${usagePercent}%` }} />
</div>
</div>
);
}
return (
<div className="px-3 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{t("storage")}</span>
<span className="text-foreground tabular-nums">
{formatFileSize(quota.used)} / {formatFileSize(quota.total)}
</span>
</div>
<div className="mt-1 w-full bg-muted rounded-full h-1">
<div className={cn(barColor, "h-1 rounded-full transition-all")} style={{ width: `${usagePercent}%` }} />
</div>
</div>
);
}
export function Sidebar({
mailboxes = [],
selectedMailbox = "",
selectedKeyword = null,
onMailboxSelect,
onTagSelect,
onCompose,
onLogout,
onSidebarClose,
onSearch,
onClearSearch,
activeSearchQuery = "",
quota,
isPushConnected = false,
onUnreadFilterClick,
className,
}: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [tagsExpanded, setTagsExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarTagsExpanded');
return stored !== null ? JSON.parse(stored) : true;
} catch { return true; }
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const tagCounts = useEmailStore(s => s.tagCounts);
const t = useTranslations('sidebar');
useEffect(() => {
setSearchQuery(activeSearchQuery);
}, [activeSearchQuery]);
useEffect(() => {
const stored = localStorage.getItem('expandedMailboxes');
if (stored) {
@@ -361,13 +405,6 @@ export function Sidebar({
});
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim() && onSearch) {
onSearch(searchQuery);
}
};
const mailboxTree = buildMailboxTree(mailboxes);
useEffect(() => {
@@ -407,12 +444,12 @@ export function Sidebar({
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
"bg-secondary border-border",
"max-lg:w-full",
isCollapsed ? "lg:w-16" : "lg:w-64",
isCollapsed ? "lg:w-12" : "lg:w-full",
className
)}
>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-3" : "gap-2 px-4 py-3")}>
<Button
variant="ghost"
size="icon"
@@ -426,56 +463,28 @@ export function Sidebar({
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden lg:flex"
onClick={toggleSidebarCollapsed}
className="hidden lg:flex flex-shrink-0"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
<Menu className="w-5 h-5" />
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button>
{!isCollapsed && (
<Button onClick={onCompose} className="flex-1" title={t("compose_hint")}>
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
{!isCollapsed && primaryIdentity && (
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate" title={primaryIdentity.name}>
{primaryIdentity.name}
</p>
<p className="text-xs text-muted-foreground truncate" title={primaryIdentity.email}>
{primaryIdentity.email}
</p>
</div>
)}
</div>
{/* Vacation Banner */}
{!isCollapsed && <VacationBanner />}
{/* Search + Advanced Filter Toggle */}
{!isCollapsed && (
<div className="px-4 py-3">
<div className="flex items-center gap-1.5">
<form onSubmit={handleSearch} className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder_hint")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9", searchQuery && "pr-8")}
data-search-input
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
onClearSearch?.();
}}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t('clear_search')}
>
<X className="w-4 h-4" />
</button>
)}
</form>
<AdvancedSearchToggle />
</div>
</div>
)}
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto">
<div className="py-1">
@@ -489,62 +498,114 @@ export function Sidebar({
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedMailbox}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
/>
))}
</>
)}
</div>
{/* Tags Section */}
{emailKeywords.length > 0 && (
<>
<div
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
isCollapsed ? "justify-center px-1" : "px-2",
"text-foreground hover:bg-muted"
)}
>
{!isCollapsed && (
<button
onClick={() => {
setTagsExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarTagsExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
title={tagsExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
>
{tagsExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
)}
<button
onClick={() => {
if (isCollapsed) return;
setTagsExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarTagsExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"flex items-center px-1 rounded",
"transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left"
)}
style={{ paddingBlock: 'var(--density-sidebar-py)', ...(isCollapsed ? {} : { paddingLeft: '4px' }) }}
title={isCollapsed ? t("tags") : undefined}
>
<Tag className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
!isCollapsed && "mr-2",
tagsExpanded && "text-primary"
)} />
{!isCollapsed && (
<span className="flex-1 truncate">{t("tags")}</span>
)}
</button>
</div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
<div className="relative">
{emailKeywords.map((kw) => {
const isSelected = selectedKeyword === kw.id;
return (
<TagItem
key={kw.id}
kw={kw}
isSelected={isSelected}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
totalCount={tagCounts[kw.id]?.total ?? 0}
unreadCount={tagCounts[kw.id]?.unread ?? 0}
/>
);
})}
</div>
)}
</>
)}
</div>
{/* Footer: Storage Quota + Sign Out + Push Status */}
<div className="border-t border-border">
<StorageQuota quota={quota ?? null} isCollapsed={isCollapsed} />
<div className={cn(
"flex items-center border-t border-border",
isCollapsed ? "justify-center py-2" : "justify-between px-3 py-2"
)}>
{onLogout && (
<button
onClick={onLogout}
className={cn(
"flex items-center gap-2 rounded-md transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-muted",
isCollapsed ? "p-2" : "px-2 py-1.5"
)}
title={t("sign_out")}
>
<LogOut className="w-4 h-4" />
{!isCollapsed && t("sign_out")}
</button>
)}
{!isCollapsed && (
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
<span className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1",
"bg-popover text-popover-foreground text-xs rounded shadow-lg",
"whitespace-nowrap opacity-0 group-hover:opacity-100",
"pointer-events-none transition-opacity duration-200 z-50"
)}>
{isPushConnected ? t("push_connected") : t("push_disconnected")}
</span>
</span>
)}
</div>
{/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? (
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")}>
<PenSquare className="w-5 h-5" />
</Button>
) : (
<Button onClick={onCompose} className="w-full" title={t("compose_hint")}>
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
)}
</div>
</div>
);
@@ -0,0 +1,142 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { KeywordSettings } from '../keyword-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
// Mock SettingsSection to just render children
vi.mock('../settings-section', () => ({
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('KeywordSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
});
it('renders all default keywords', () => {
render(<KeywordSettings />);
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.getByText(kw.label)).toBeInTheDocument();
});
});
it('shows keyword JMAP id', () => {
render(<KeywordSettings />);
expect(screen.getByText('$label:red')).toBeInTheDocument();
expect(screen.getByText('$label:blue')).toBeInTheDocument();
});
it('renders add keyword button', () => {
render(<KeywordSettings />);
expect(screen.getByText('add_keyword')).toBeInTheDocument();
});
it('renders reset defaults button', () => {
render(<KeywordSettings />);
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
});
it('shows add form when add button clicked', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.getByPlaceholderText('label_placeholder')).toBeInTheDocument();
// Cancel and save buttons should appear
expect(screen.getByText('cancel')).toBeInTheDocument();
expect(screen.getByText('add')).toBeInTheDocument();
});
it('adds a new keyword through the form', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'Important' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords).toHaveLength(DEFAULT_KEYWORDS.length + 1);
expect(keywords[keywords.length - 1].label).toBe('Important');
expect(keywords[keywords.length - 1].id).toBe('important');
});
it('prevents adding keyword with duplicate id', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'Red' } });
// Should show duplicate warning
expect(screen.getByText('id_exists')).toBeInTheDocument();
});
it('cancels add form when cancel clicked', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.getByPlaceholderText('label_placeholder')).toBeInTheDocument();
fireEvent.click(screen.getByText('cancel'));
expect(screen.queryByPlaceholderText('label_placeholder')).not.toBeInTheDocument();
});
it('deletes keyword when delete button clicked', () => {
render(<KeywordSettings />);
// Find delete buttons (title="delete")
const deleteButtons = screen.getAllByTitle('delete');
expect(deleteButtons.length).toBe(DEFAULT_KEYWORDS.length);
// Delete the first keyword
fireEvent.click(deleteButtons[0]);
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 1);
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')).toBeUndefined();
});
it('shows edit form when edit button clicked', () => {
render(<KeywordSettings />);
const editButtons = screen.getAllByTitle('edit');
fireEvent.click(editButtons[0]); // edit first keyword (Red)
const input = screen.getByDisplayValue('Red');
expect(input).toBeInTheDocument();
expect(screen.getByText('save')).toBeInTheDocument();
});
it('updates keyword label through edit form', () => {
render(<KeywordSettings />);
const editButtons = screen.getAllByTitle('edit');
fireEvent.click(editButtons[0]); // edit "Red"
const input = screen.getByDisplayValue('Red');
fireEvent.change(input, { target: { value: 'Crimson' } });
fireEvent.click(screen.getByText('save'));
const kw = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red');
expect(kw?.label).toBe('Crimson');
});
it('resets to defaults when reset button clicked', () => {
// Modify keywords first
useSettingsStore.getState().removeKeyword('red');
useSettingsStore.getState().removeKeyword('blue');
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
render(<KeywordSettings />);
fireEvent.click(screen.getByText('reset_defaults'));
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
});
it('normalizes label to id correctly', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'My Custom Tag!' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
const added = keywords[keywords.length - 1];
expect(added.id).toBe('my-custom-tag');
expect(added.label).toBe('My Custom Tag!');
});
});
@@ -0,0 +1,517 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
function PasswordChangeSection() {
const t = useTranslations('settings.security');
const { changePassword, isSaving } = useAccountSecurityStore();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (newPassword.length < 8) {
setError(t('password.error_min_length'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('password.error_mismatch'));
return;
}
try {
await changePassword(currentPassword, newPassword);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
toast.success(t('password.success'));
} catch (err) {
const msg = err instanceof Error ? err.message : t('password.error_generic');
setError(msg);
toast.error(t('password.error_title'), msg);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<Key className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('password.title')}</h4>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
<div className="relative">
<Input
type={showCurrent ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowCurrent(!showCurrent)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.new')}</label>
<div className="relative">
<Input
type={showNew ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNew ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('password.confirm')}</label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Button
type="submit"
size="sm"
disabled={isSaving || !currentPassword || !newPassword || !confirmPassword}
>
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{t('password.submit')}
</Button>
</form>
</div>
);
}
function DisplayNameSection() {
const t = useTranslations('settings.security');
const { displayName, updateDisplayName, isSaving, isLoadingPrincipal } = useAccountSecurityStore();
const [name, setName] = useState(displayName);
const [saved, setSaved] = useState(false);
useEffect(() => {
setName(displayName);
}, [displayName]);
const handleSave = async () => {
try {
await updateDisplayName(name);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
toast.success(t('display_name.success'));
} catch (err) {
toast.error(t('display_name.error'), err instanceof Error ? err.message : undefined);
}
};
if (isLoadingPrincipal) {
return (
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</SettingItem>
);
}
return (
<SettingItem label={t('display_name.label')} description={t('display_name.description')}>
<div className="flex items-center gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-48"
/>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving || name === displayName}
>
{saved ? <Check className="w-4 h-4" /> : isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : t('display_name.save')}
</Button>
</div>
</SettingItem>
);
}
function TotpSection() {
const t = useTranslations('settings.security');
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [totpUrl, setTotpUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleToggle = async (enable: boolean) => {
try {
if (enable) {
const url = await enableTotp();
setTotpUrl(url);
toast.success(t('totp.enabled'));
} else {
await disableTotp();
setTotpUrl(null);
toast.success(t('totp.disabled'));
}
} catch (err) {
toast.error(
enable ? t('totp.enable_error') : t('totp.disable_error'),
err instanceof Error ? err.message : undefined
);
}
};
const handleCopyUrl = () => {
if (totpUrl) {
navigator.clipboard.writeText(totpUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
};
if (isLoadingAuth) {
return (
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</SettingItem>
);
}
return (
<div className="space-y-3">
<SettingItem label={t('totp.label')} description={t('totp.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={otpEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{otpEnabled ? t('totp.active') : t('totp.inactive')}
</span>
</div>
</SettingItem>
{totpUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
{totpUrl}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
</div>
</div>
)}
</div>
);
}
function AppPasswordsSection() {
const t = useTranslations('settings.security');
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newPassword, setNewPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const generatePassword = useCallback(() => {
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let result = '';
const array = new Uint8Array(24);
crypto.getRandomValues(array);
for (const byte of array) {
result += chars[byte % chars.length];
}
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
return result.match(/.{1,4}/g)?.join('-') ?? result;
}, []);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
const password = newPassword || generatePassword();
try {
await addAppPassword(newName.trim(), password);
setNewName('');
setNewPassword('');
setShowAdd(false);
toast.success(t('app_passwords.added'));
} catch (err) {
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined);
}
};
const handleRemove = async (name: string) => {
try {
await removeAppPassword(name);
toast.success(t('app_passwords.removed'));
} catch (err) {
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
}
};
if (isLoadingAuth) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
</div>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Smartphone className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 mr-1" />
{t('app_passwords.add')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
{showAdd && (
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('app_passwords.name_placeholder')}
required
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('app_passwords.password_placeholder')}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
{t('app_passwords.generate')}
</Button>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{t('app_passwords.create')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowAdd(false)}>
{t('app_passwords.cancel')}
</Button>
</div>
</form>
)}
{appPasswords.length > 0 ? (
<div className="space-y-1">
{appPasswords.map((name) => (
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
<span className="text-sm text-foreground">{name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(name)}
disabled={isSaving}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p>
)}
</div>
);
}
function EncryptionSection() {
const t = useTranslations('settings.security');
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore();
const handleToggle = async (enabled: boolean) => {
try {
if (enabled) {
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
toast.success(t('encryption.enabled'));
} else {
await updateEncryption({ type: 'disabled' });
toast.success(t('encryption.disabled_success'));
}
} catch (err) {
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
}
};
if (isLoadingCrypto) {
return (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</SettingItem>
);
}
const isEnabled = encryptionType !== 'disabled';
return (
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
<div className="flex items-center gap-2">
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<ToggleSwitch
checked={isEnabled}
onChange={handleToggle}
disabled={isSaving}
/>
)}
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
</span>
</div>
</SettingItem>
);
}
export function AccountSecuritySettings() {
const t = useTranslations('settings.security');
const { isStalwart, isProbing, probe, fetchAll } = useAccountSecurityStore();
const { isAuthenticated } = useAuthStore();
useEffect(() => {
if (isAuthenticated && isStalwart === null) {
probe().then((detected) => {
if (detected) {
fetchAll();
}
});
}
}, [isAuthenticated, isStalwart, probe, fetchAll]);
if (isProbing) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="flex items-center gap-2 py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{t('detecting')}</span>
</div>
</SettingsSection>
);
}
if (isStalwart === false) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<p className="text-sm text-muted-foreground py-4">{t('not_available')}</p>
</SettingsSection>
);
}
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-6">
{/* Password Change */}
<PasswordChangeSection />
<div className="border-t border-border" />
{/* Display Name */}
<DisplayNameSection />
<div className="border-t border-border" />
{/* Two-Factor Authentication */}
<div>
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
</div>
<TotpSection />
</div>
<div className="border-t border-border" />
{/* App Passwords */}
<AppPasswordsSection />
<div className="border-t border-border" />
{/* Encryption at Rest */}
<div>
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
</div>
<EncryptionSection />
</div>
</div>
</SettingsSection>
);
}
+15 -1
View File
@@ -3,14 +3,16 @@
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } =
const { debugMode, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -66,6 +68,18 @@ export function AdvancedSettings() {
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
{/* Settings Sync */}
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
</SettingItem>
)}
{/* Sender Favicons (Experimental) */}
<SettingItem label={t('sender_favicons.label')} description={t('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
{/* Export Settings */}
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
+80 -5
View File
@@ -2,14 +2,67 @@
import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/settings-store';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
compact: { py: 'py-1', gap: 'gap-2', showAvatar: true, showPreview: false },
regular: { py: 'py-2.5', gap: 'gap-3', showAvatar: true, showPreview: true },
comfortable: { py: 'py-4', gap: 'gap-4', showAvatar: true, showPreview: true },
};
function DensityPreview({ density }: { density: Density }) {
const cfg = DENSITY_PREVIEW[density];
const rows = [
{ unread: true, sender: 'Alice Johnson', subject: 'Project update — Q1 roadmap', preview: 'Here are the latest numbers from…' },
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
];
return (
<div className="mt-3 rounded-lg border border-border overflow-hidden bg-background text-xs select-none">
{rows.map((row, i) => (
<div
key={i}
className={cn(
"flex items-center px-3 border-b border-border last:border-b-0",
cfg.py,
cfg.gap
)}
>
{cfg.showAvatar && (
<div className={cn(
"flex-shrink-0 rounded-full bg-muted",
density === 'comfortable' ? "w-8 h-8" : "w-6 h-6"
)} />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={cn("truncate", row.unread ? "font-semibold text-foreground" : "text-muted-foreground")}>
{row.sender}
</span>
<span className="text-[10px] text-muted-foreground flex-shrink-0 tabular-nums">12:00</span>
</div>
<div className={cn("truncate", row.unread ? "font-medium text-foreground" : "text-foreground/80")}>
{row.subject}
</div>
{cfg.showPreview && (
<div className="truncate text-muted-foreground/70">{row.preview}</div>
)}
</div>
</div>
))}
</div>
);
}
export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const { theme, setTheme } = useThemeStore();
const { fontSize, listDensity, animationsEnabled, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -44,19 +97,41 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* List Density */}
{/* Density */}
<SettingItem label={t('list_density.label')} description={t('list_density.description')}>
<RadioGroup
value={listDensity}
value={density}
onChange={(value) =>
updateSetting('listDensity', value as 'compact' | 'regular' | 'comfortable')
updateSetting('density', value as Density)
}
options={[
{ value: 'extra-compact', label: t('list_density.extra_compact') },
{ value: 'compact', label: t('list_density.compact') },
{ value: 'regular', label: t('list_density.regular') },
{ value: 'comfortable', label: t('list_density.comfortable') },
]}
/>
<DensityPreview density={density} />
</SettingItem>
{/* Toolbar Position */}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
<RadioGroup
value={toolbarPosition}
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
options={[
{ value: 'top', label: t('toolbar_position.top') },
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
]}
/>
</SettingItem>
{/* Toolbar Labels */}
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
<ToggleSwitch
checked={showToolbarLabels}
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
/>
</SettingItem>
{/* Animations */}
@@ -0,0 +1,611 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Check, X, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
function CalendarColorPicker({
value,
onChange,
allowCustom,
}: {
value: string;
onChange: (color: string) => void;
allowCustom?: boolean;
}) {
const selectedIsPreset = CALENDAR_COLORS.includes(value);
return (
<div className="flex flex-wrap items-center gap-1.5">
{CALENDAR_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
value === color && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
style={{ backgroundColor: color }}
aria-label={color}
/>
))}
{allowCustom && (
<label
className={cn(
"relative w-6 h-6 rounded-full cursor-pointer transition-transform hover:scale-110 overflow-hidden border-2 border-dashed border-muted-foreground/40",
!selectedIsPreset && value && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
style={!selectedIsPreset && value ? { backgroundColor: value } : undefined}
title="Custom color"
>
<input
type="color"
value={value || "#3b82f6"}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
/>
{(selectedIsPreset || !value) && (
<span className="absolute inset-0 flex items-center justify-center text-muted-foreground text-xs font-bold">+</span>
)}
</label>
)}
</div>
);
}
function CalendarEditForm({
initial,
onSave,
onCancel,
isLoading,
}: {
initial?: { name: string; color: string };
onSave: (data: { name: string; color: string }) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations('calendar.management');
const [name, setName] = useState(initial?.name || '');
const [color, setColor] = useState(initial?.color || '#3b82f6');
const isValid = name.trim().length > 0;
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid) onSave({ name: name.trim(), color });
if (e.key === 'Escape') onCancel();
}}
placeholder={t('name_placeholder')}
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('color')}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => isValid && onSave({ name: name.trim(), color })}
disabled={isLoading || !isValid}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{initial ? t('save') : t('create')}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
</div>
);
}
export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const [isCreating, setIsCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import');
const tSub = useTranslations('calendar.subscription');
const colorPickerRef = useRef<HTMLDivElement>(null);
// Load calendars if not yet loaded
useEffect(() => {
if (client && calendars.length === 0) {
fetchCalendars(client);
}
}, [client, calendars.length, fetchCalendars]);
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
} catch {
toast.error(tSub('refresh_error'));
} finally {
setRefreshingSubId(null);
}
};
const handleDeleteSubscription = async (subId: string) => {
if (!client) return;
setIsLoading(true);
try {
await removeICalSubscription(client, subId);
setDeletingSubId(null);
toast.success(tSub('deleted'));
} catch {
toast.error(tSub('delete_error'));
} finally {
setIsLoading(false);
}
};
// Close color picker on click outside
useEffect(() => {
if (!colorPickerId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setColorPickerId(null);
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId]);
const handleCreate = async (data: { name: string; color: string }) => {
if (!client) return;
setIsLoading(true);
try {
await createCalendar(client, {
name: data.name,
color: data.color,
isVisible: true,
isSubscribed: true,
});
setIsCreating(false);
toast.success(t('calendar_created'));
} catch {
toast.error(t('error_create'));
} finally {
setIsLoading(false);
}
};
const handleUpdate = async (calendarId: string, data: { name: string; color: string }) => {
if (!client) return;
setIsLoading(true);
try {
await updateCalendar(client, calendarId, { name: data.name, color: data.color });
setEditingId(null);
toast.success(t('calendar_updated'));
} catch {
toast.error(t('error_update'));
} finally {
setIsLoading(false);
}
};
const handleColorChange = async (calendarId: string, color: string) => {
if (!client) return;
try {
await updateCalendar(client, calendarId, { color });
toast.success(t('color_updated'));
} catch {
toast.error(t('error_update'));
}
setColorPickerId(null);
};
const handleDelete = async (calendarId: string) => {
if (!client) return;
setIsLoading(true);
try {
await removeCalendar(client, calendarId);
setDeletingId(null);
toast.success(t('calendar_deleted'));
} catch {
toast.error(t('error_delete'));
} finally {
setIsLoading(false);
}
};
const handleClear = async (calendarId: string) => {
if (!client) return;
setIsLoading(true);
try {
const count = await clearCalendarEvents(client, calendarId);
setClearingId(null);
toast.success(t('events_cleared', { count }));
} catch {
toast.error(t('error_clear'));
} finally {
setIsLoading(false);
}
};
const buildCalDavUrl = (calendarId: string) => {
if (!serverUrl || !username) return null;
const base = serverUrl.replace(/\/$/, '');
return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
};
const handleCopyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
toast.success(t('url_copied'));
} catch {
// Fallback for non-HTTPS contexts
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
toast.success(t('url_copied'));
}
};
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
const color = cal.color || '#3b82f6';
if (editingId === cal.id) {
return (
<CalendarEditForm
key={cal.id}
initial={{ name: cal.name, color }}
onSave={(data) => handleUpdate(cal.id, data)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
);
}
if (deletingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_delete', { name: cal.name })}
</p>
<button
onClick={() => handleDelete(cal.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
if (clearingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-amber-500/5 rounded-md border border-amber-500/20">
<Eraser className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_clear', { name: cal.name })}
</p>
<button
onClick={() => handleClear(cal.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
>
{t('clear_events')}
</button>
<button
onClick={() => setClearingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={cal.id}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
{/* Color swatch - clickable to change color */}
<div className="relative">
<button
type="button"
onClick={() => setColorPickerId(colorPickerId === cal.id ? null : cal.id)}
className="w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ backgroundColor: color }}
title={t('change_color')}
/>
{/* Inline color picker popover */}
{colorPickerId === cal.id && (
<div
ref={colorPickerRef}
className="absolute left-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<CalendarColorPicker
value={color}
onChange={(c) => handleColorChange(cal.id, c)}
allowCustom
/>
</div>
)}
</div>
<CalendarIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{cal.name}</span>
{(() => {
const caldavUrl = buildCalDavUrl(cal.id);
if (!caldavUrl) return null;
return (
<div className="flex items-center gap-1 mt-0.5">
<Link className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-muted-foreground truncate" title={caldavUrl}>
{caldavUrl}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCopyUrl(caldavUrl);
}}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
title={t('copy_url')}
>
<Copy className="w-3 h-3" />
</button>
</div>
);
})()}
</div>
{cal.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t('default')}
</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setClearingId(cal.id)}
className="p-1.5 rounded-md hover:bg-amber-500/10 text-muted-foreground hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
title={t('clear_events')}
>
<Eraser className="w-3.5 h-3.5" />
</button>
{!cal.isDefault && (
<button
type="button"
onClick={() => setDeletingId(cal.id)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t('delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
{isCreating ? (
<CalendarEditForm
onSave={handleCreate}
onCancel={() => setIsCreating(false)}
isLoading={isLoading}
/>
) : (
<div className="flex gap-2">
<button
type="button"
onClick={() => setIsCreating(true)}
className="flex items-center gap-2 flex-1 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Plus className="w-4 h-4" />
{t('add_calendar')}
</button>
<button
type="button"
onClick={() => setShowImportModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Upload className="w-4 h-4" />
{tImport('title')}
</button>
<button
type="button"
onClick={() => setShowSubscriptionModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
<Globe className="w-4 h-4" />
{tSub('title')}
</button>
</div>
)}
</div>
{/* iCal Subscriptions */}
{icalSubscriptions.length > 0 && (
<div className="mt-6 space-y-2">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
{tSub('section_title')}
</h4>
{icalSubscriptions.map((sub) => {
if (deletingSubId === sub.id) {
return (
<div key={sub.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{tSub('confirm_delete', { name: sub.name })}
</p>
<button
onClick={() => handleDeleteSubscription(sub.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingSubId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={sub.id}
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
>
<span
className="w-5 h-5 rounded-full shrink-0"
style={{ backgroundColor: sub.color }}
/>
<Globe className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{sub.name}</span>
<span className="text-xs text-muted-foreground truncate block" title={sub.url}>
{sub.url}
</span>
{sub.lastRefreshed && (
<span className="text-xs text-muted-foreground">
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => handleRefreshSubscription(sub.id)}
disabled={refreshingSubId === sub.id}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title={tSub('refresh')}
>
<RefreshCw className={cn("w-3.5 h-3.5", refreshingSubId === sub.id && "animate-spin")} />
</button>
<button
type="button"
onClick={() => setDeletingSubId(sub.id)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={tSub('unsubscribe')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
{showImportModal && client && (
<ICalImportModal
calendars={calendars}
client={client}
onClose={() => setShowImportModal(false)}
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
onClose={() => setShowSubscriptionModal(false)}
/>
)}
</SettingsSection>
);
}
+18 -1
View File
@@ -11,7 +11,14 @@ export function CalendarSettings() {
const tDays = useTranslations('calendar.days');
const { viewMode, setViewMode } = useCalendarStore();
const { timeFormat, firstDayOfWeek, calendarNotificationsEnabled, calendarNotificationSound, updateSetting } = useSettingsStore();
const {
timeFormat,
firstDayOfWeek,
calendarNotificationsEnabled,
calendarNotificationSound,
calendarInvitationParsingEnabled,
updateSetting,
} = useSettingsStore();
return (
<SettingsSection title={t('title')}>
@@ -71,6 +78,16 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('invitation_parsing')}
description={t('invitation_parsing_desc')}
>
<ToggleSwitch
checked={calendarInvitationParsingEnabled}
onChange={(checked) => updateSetting('calendarInvitationParsingEnabled', checked)}
/>
</SettingItem>
</SettingsSection>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem } from "./settings-section";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
export function ContactsSettings() {
const t = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const {
contacts,
supportsSync,
importContacts,
} = useContactStore();
const [showImport, setShowImport] = useState(false);
const individuals = contacts.filter(c => c.kind !== "group");
const handleImport = useCallback(async (importedContacts: import("@/lib/jmap/types").ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
const handleExport = () => {
if (individuals.length > 0) {
exportContacts(individuals);
toast.success(t("export.success", { count: individuals.length }));
}
};
if (showImport) {
return (
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={() => setShowImport(false)}
/>
</div>
);
}
return (
<SettingsSection
title={tSettings("title")}
description={tSettings("description")}
>
<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
>
<Button variant="outline" size="sm" onClick={() => setShowImport(true)}>
<Upload className="w-4 h-4 mr-2" />
{t("import.title")}
</Button>
</SettingItem>
<SettingItem
label={tSettings("export_label")}
description={tSettings("export_description")}
>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={individuals.length === 0}
>
<Download className="w-4 h-4 mr-2" />
{t("export.title")}
</Button>
</SettingItem>
</SettingsSection>
);
}
+37 -8
View File
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight } from 'lucide-react';
import { ChevronRight, AlertTriangle } from 'lucide-react';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
@@ -14,9 +14,11 @@ export function EmailSettings() {
const {
markAsReadDelay,
deleteAction,
permanentlyDeleteJunk,
showPreview,
emailsPerPage,
externalContentPolicy,
mailAttachmentAction,
trustedSenders,
updateSetting,
} = useSettingsStore();
@@ -47,13 +49,29 @@ export function EmailSettings() {
{/* Delete Action */}
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')}>
<Select
value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
options={[
{ value: 'trash', label: t('delete_action.trash') },
{ value: 'permanent', label: t('delete_action.permanent') },
]}
<div className="flex flex-col gap-2">
<Select
value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
options={[
{ value: 'trash', label: t('delete_action.trash') },
{ value: 'permanent', label: t('delete_action.permanent') },
]}
/>
{deleteAction === 'permanent' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-destructive/10 text-destructive text-xs">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{t('delete_action.warning')}</span>
</div>
)}
</div>
</SettingItem>
{/* Permanently Delete Junk */}
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
<ToggleSwitch
checked={permanentlyDeleteJunk}
onChange={(checked) => updateSetting('permanentlyDeleteJunk', checked)}
/>
</SettingItem>
@@ -62,6 +80,17 @@ export function EmailSettings() {
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
<Select
value={mailAttachmentAction}
onChange={(value) => updateSetting('mailAttachmentAction', value as 'preview' | 'download')}
options={[
{ value: 'preview', label: t('attachment_click_action.preview') },
{ value: 'download', label: t('attachment_click_action.download') },
]}
/>
</SettingItem>
{/* Emails Per Page */}
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
<Select
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Home, ChevronRight, ChevronDown } from "lucide-react";
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
import { cn } from "@/lib/utils";
interface SampleFile {
name: string;
isFolder: boolean;
size: number;
modified: string;
hidden?: boolean;
thumbnailUrl?: string;
}
const SAMPLE_FILES: SampleFile[] = [
{ name: "Documents", isFolder: true, size: 0, modified: "2026-03-10" },
{ name: "Photos", isFolder: true, size: 0, modified: "2026-03-14" },
{ name: "report.pdf", isFolder: false, size: 245000, modified: "2026-03-15" },
{ name: "notes.md", isFolder: false, size: 1200, modified: "2026-03-12" },
{ name: "vacation.jpg", isFolder: false, size: 3400000, modified: "2026-03-08", thumbnailUrl: "/branding/Bulwark_Logo_Color.png" },
{ name: "song.mp3", isFolder: false, size: 5200000, modified: "2026-03-01" },
{ name: ".config", isFolder: false, size: 340, modified: "2026-02-20", hidden: true },
];
function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
const cls = size === "sm" ? "w-4 h-4 flex-shrink-0" : "w-8 h-8 flex-shrink-0";
if (file.isFolder) {
return <Folder className={cn(cls, colored ? "text-blue-500" : "text-muted-foreground")} />;
}
const ext = file.name.split(".").pop()?.toLowerCase();
switch (ext) {
case "jpg": case "png": case "gif":
return <ImageIcon className={cn(cls, colored ? "text-emerald-500" : "text-muted-foreground")} />;
case "mp3": case "wav":
return <FileAudio className={cn(cls, colored ? "text-purple-500" : "text-muted-foreground")} />;
case "pdf":
return <FileText className={cn(cls, colored ? "text-red-600" : "text-muted-foreground")} />;
case "md": case "json": case "js": case "ts":
return <FileCode className={cn(cls, colored ? "text-yellow-600" : "text-muted-foreground")} />;
default:
return <File className={cn(cls, "text-muted-foreground")} />;
}
}
function formatSize(bytes: number): string {
if (bytes === 0) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
const sortedFiles = useMemo(() => {
let files = SAMPLE_FILES.filter((f) => {
if (!settings.showHiddenFiles && f.hidden) return false;
if (settings.folderLayout === "sidebar" && f.isFolder) return false;
return true;
});
files.sort((a, b) => {
// Folders first
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
let cmp = 0;
switch (settings.defaultSortKey) {
case "name": cmp = a.name.localeCompare(b.name); break;
case "size": cmp = a.size - b.size; break;
case "modified": cmp = a.modified.localeCompare(b.modified); break;
}
return settings.defaultSortDir === "desc" ? -cmp : cmp;
});
return files;
}, [settings.showHiddenFiles, settings.folderLayout, settings.defaultSortKey, settings.defaultSortDir]);
const listView = (
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-3 px-2 py-1 text-[10px] font-medium text-muted-foreground border-b border-border bg-muted/50">
<span className="flex-1 min-w-0">Name</span>
<span className="w-14 text-right">Size</span>
<span className="w-16 text-right">Modified</span>
</div>
{sortedFiles.map((file) => (
<div
key={file.name}
className={cn(
"flex items-center gap-2 px-2 py-1.5 border-b border-border last:border-b-0 transition-colors hover:bg-muted/50",
file.hidden && "opacity-50"
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm")
) : null}
<span className={cn("flex-1 min-w-0 truncate text-[11px]", file.isFolder && "font-medium")}>
{file.name}
</span>
<span className="w-14 text-right text-[10px] text-muted-foreground tabular-nums">
{formatSize(file.size)}
</span>
<span className="w-16 text-right text-[10px] text-muted-foreground tabular-nums">
{file.modified.slice(5)}
</span>
</div>
))}
</div>
);
const gridView = (
<div className="flex-1 min-w-0 p-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(4.5rem,1fr))] gap-1.5">
{sortedFiles.map((file) => (
<div
key={file.name}
className={cn(
"flex flex-col items-center gap-1 p-2 rounded-md transition-colors hover:bg-muted/50",
file.hidden && "opacity-50"
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg")
) : (
<div className="w-8 h-8" />
)}
<span className={cn("text-[9px] truncate w-full text-center", file.isFolder && "font-medium")}>
{file.name}
</span>
</div>
))}
</div>
</div>
);
const sidebar = settings.folderLayout === "sidebar" && (
<div className="w-24 border-r border-border bg-muted/30 py-1.5 flex-shrink-0">
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-medium text-foreground">
<Home className="w-3 h-3 flex-shrink-0" />
<span className="truncate">Files</span>
</div>
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] text-foreground bg-accent rounded-sm mx-1">
<ChevronDown className="w-2.5 h-2.5 flex-shrink-0" />
<FolderOpen className="w-3 h-3 flex-shrink-0 text-blue-500" />
<span className="truncate">Documents</span>
</div>
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] text-muted-foreground" style={{ paddingLeft: "1.25rem" }}>
<ChevronRight className="w-2.5 h-2.5 flex-shrink-0" />
<Folder className="w-3 h-3 flex-shrink-0 text-blue-500" />
<span className="truncate">Photos</span>
</div>
</div>
);
return (
<div className="mt-4 rounded-lg border border-border overflow-hidden bg-background text-xs select-none">
<div className="flex" style={{ minHeight: "10rem" }}>
{sidebar}
{settings.defaultViewMode === "grid" ? gridView : listView}
</div>
</div>
);
}
export function FilesSettingsComponent() {
const t = useTranslations("settings.files");
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
// Listen for external changes (e.g. if file-browser updates settings)
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === "files-settings") {
setSettings(loadFilesSettings());
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const update = useCallback((patch: Partial<FilesSettings>) => {
setSettings(prev => {
const next = { ...prev, ...patch };
saveFilesSettings(next);
return next;
});
}, []);
return (
<div>
<div className="sticky top-0 z-10 bg-background pb-4 -mx-4 px-4 -mt-4 pt-4 lg:-mx-6 lg:px-6 lg:-mt-6 lg:pt-6 border-b border-border mb-6">
<p className="text-sm font-medium text-foreground mb-1">{t("preview.label")}</p>
<FilesSettingsPreview settings={settings} />
</div>
<div className="space-y-8">
<SettingsSection title={t("display.title")} description={t("display.description")}>
<SettingItem label={t("folder_layout.label")} description={t("folder_layout.description")}>
<RadioGroup
value={settings.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
options={[
{ value: "inline", label: t("folder_layout.inline") },
{ value: "sidebar", label: t("folder_layout.sidebar") },
]}
/>
</SettingItem>
<SettingItem label={t("default_view.label")} description={t("default_view.description")}>
<RadioGroup
value={settings.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as "list" | "grid" })}
options={[
{ value: "list", label: t("default_view.list") },
{ value: "grid", label: t("default_view.grid") },
]}
/>
</SettingItem>
<SettingItem label={t("default_sort.label")} description={t("default_sort.description")}>
<RadioGroup
value={settings.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as "name" | "size" | "modified" })}
options={[
{ value: "name", label: t("default_sort.name") },
{ value: "size", label: t("default_sort.size") },
{ value: "modified", label: t("default_sort.modified") },
]}
/>
</SettingItem>
<SettingItem label={t("sort_direction.label")} description={t("sort_direction.description")}>
<RadioGroup
value={settings.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as "asc" | "desc" })}
options={[
{ value: "asc", label: t("sort_direction.ascending") },
{ value: "desc", label: t("sort_direction.descending") },
]}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("icons.title")} description={t("icons.description")}>
<SettingItem label={t("show_icons.label")} description={t("show_icons.description")}>
<ToggleSwitch
checked={settings.showIcons}
onChange={(v) => update({ showIcons: v })}
/>
</SettingItem>
<SettingItem label={t("colored_icons.label")} description={t("colored_icons.description")}>
<ToggleSwitch
checked={settings.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!settings.showIcons}
/>
</SettingItem>
<SettingItem label={t("show_thumbnails.label")} description={t("show_thumbnails.description")}>
<ToggleSwitch
checked={settings.showThumbnails}
onChange={(v) => update({ showThumbnails: v })}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("behavior.title")} description={t("behavior.description")}>
<SettingItem label={t("show_hidden.label")} description={t("show_hidden.description")}>
<ToggleSwitch
checked={settings.showHiddenFiles}
onChange={(v) => update({ showHiddenFiles: v })}
/>
</SettingItem>
</SettingsSection>
</div>
</div>
);
}
+511
View File
@@ -0,0 +1,511 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { toast } from '@/stores/toast-store';
import { SettingsSection, SettingItem, Select } from './settings-section';
import {
Plus, Pencil, Trash2, Check, X, FolderPlus, Folder,
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
type LucideIcon,
} from 'lucide-react';
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
import { ChevronRight, ChevronDown } from 'lucide-react';
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
const ROLE_ICONS: Record<string, LucideIcon> = {
inbox: Inbox,
drafts: FileText,
sent: Send,
trash: Trash,
junk: ShieldAlert,
archive: Archive,
};
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
{ name: 'Folder', icon: Folder },
{ name: 'Star', icon: Star },
{ name: 'Heart', icon: Heart },
{ name: 'Bookmark', icon: Bookmark },
{ name: 'Tag', icon: Tag },
{ name: 'Flag', icon: Flag },
{ name: 'Briefcase', icon: Briefcase },
{ name: 'Users', icon: Users },
{ name: 'Bell', icon: Bell },
{ name: 'Zap', icon: Zap },
{ name: 'Globe', icon: Globe },
{ name: 'Lock', icon: Lock },
{ name: 'Eye', icon: Eye },
{ name: 'MessageSquare', icon: MessageSquare },
{ name: 'Mail', icon: Mail },
{ name: 'Inbox', icon: Inbox },
{ name: 'Archive', icon: Archive },
{ name: 'FileText', icon: FileText },
];
function IconPicker({ currentIcon, onSelect, onClose }: {
currentIcon: string;
onSelect: (iconName: string) => void;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);
return (
<div
ref={ref}
className="absolute left-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
>
{ICON_CHOICES.map(({ name, icon: Icon }) => (
<button
key={name}
onClick={() => onSelect(name)}
className={cn(
"p-1.5 rounded-md transition-colors flex items-center justify-center",
currentIcon === name
? "bg-primary text-primary-foreground"
: "hover:bg-accent text-muted-foreground hover:text-foreground"
)}
title={name}
>
<Icon className="w-4 h-4" />
</button>
))}
</div>
);
}
export function FolderSettings() {
const t = useTranslations('settings.folders');
const { client } = useAuthStore();
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { folderIcons, setFolderIcon } = useSettingsStore();
const [isCreating, setIsCreating] = useState(false);
const [creatingParentId, setCreatingParentId] = useState<string | null>(null);
const [newFolderName, setNewFolderName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const [iconPickerId, setIconPickerId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
const folderTree = buildMailboxTree(ownMailboxes);
const getRoleMailboxId = (role: string): string => {
const mb = ownMailboxes.find(m => m.role === role);
return mb?.id ?? '';
};
const getIconForMailbox = (mb: { id: string; role?: string }): LucideIcon => {
// Custom icon takes priority for non-role folders
const customIconName = folderIcons[mb.id];
if (customIconName) {
const found = ICON_CHOICES.find(c => c.name === customIconName);
if (found) return found.icon;
}
// Role folders get their role icon
if (mb.role && ROLE_ICONS[mb.role]) return ROLE_ICONS[mb.role];
return Folder;
};
const getIconName = (mb: { id: string; role?: string }): string => {
if (folderIcons[mb.id]) return folderIcons[mb.id];
if (mb.role && ROLE_ICONS[mb.role]) {
const entry = Object.entries(ROLE_ICONS).find(([r]) => r === mb.role);
if (entry) {
const found = ICON_CHOICES.find(c => c.icon === entry[1]);
if (found) return found.name;
}
}
return 'Folder';
};
const toggleExpanded = (id: string) => {
setExpandedFolders(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const startCreateSubfolder = (parentId: string) => {
setCreatingParentId(parentId);
setIsCreating(true);
setNewFolderName('');
setExpandedFolders(prev => new Set(prev).add(parentId));
};
const handleCreate = async () => {
if (!client || !newFolderName.trim()) return;
setIsLoading(true);
try {
await createMailbox(client, newFolderName.trim(), creatingParentId ?? undefined);
setNewFolderName('');
setIsCreating(false);
setCreatingParentId(null);
toast.success(t('folder_created'));
} catch {
toast.error(t('error_create'));
} finally {
setIsLoading(false);
}
};
const handleRename = async (mailboxId: string) => {
if (!client || !editingName.trim()) return;
setIsLoading(true);
try {
await renameMailbox(client, mailboxId, editingName.trim());
setEditingId(null);
setEditingName('');
toast.success(t('folder_renamed'));
} catch {
toast.error(t('error_rename'));
} finally {
setIsLoading(false);
}
};
const handleDelete = async (mailboxId: string) => {
if (!client) return;
setIsLoading(true);
try {
await deleteMailbox(client, mailboxId);
setDeletingId(null);
toast.success(t('folder_deleted'));
} catch {
toast.error(t('error_delete'));
} finally {
setIsLoading(false);
}
};
const handleRoleChange = async (role: string, mailboxId: string) => {
if (!client) return;
setIsLoading(true);
try {
if (mailboxId === '') {
const current = ownMailboxes.find(m => m.role === role);
if (current) {
await setMailboxRole(client, current.id, null);
}
} else {
await setMailboxRole(client, mailboxId, role);
}
toast.success(t('role_updated'));
} catch {
toast.error(t('error_role'));
} finally {
setIsLoading(false);
}
};
const startEdit = (mb: { id: string; name: string }) => {
setEditingId(mb.id);
setEditingName(mb.name);
};
const cancelEdit = () => {
setEditingId(null);
setEditingName('');
};
const renderCreateInline = (parentId: string | null, depth: number) => {
if (!isCreating || creatingParentId !== parentId) return null;
const parentName = parentId ? ownMailboxes.find(m => m.id === parentId)?.name : null;
return (
<div
className="flex items-center gap-2 mt-1 p-2.5 bg-muted/30 rounded-md border border-border"
style={{ marginLeft: depth * 16 }}
>
<FolderPlus className="w-4 h-4 text-primary flex-shrink-0" />
<div className="flex-1 flex flex-col gap-1">
{parentName && (
<span className="text-xs text-muted-foreground">
{t('subfolder_of', { name: parentName })}
</span>
)}
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') {
setIsCreating(false);
setNewFolderName('');
setCreatingParentId(null);
}
}}
placeholder={parentId ? t('subfolder_name') : t('new_folder_name')}
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
</div>
<button
onClick={handleCreate}
disabled={isLoading || !newFolderName.trim()}
className="px-3 py-1 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{t('create')}
</button>
<button
onClick={() => {
setIsCreating(false);
setNewFolderName('');
setCreatingParentId(null);
}}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
};
const renderFolderNode = (node: MailboxNode): React.ReactNode => {
const mb = node;
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
const depth = node.depth;
const Icon = getIconForMailbox(mb);
if (editingId === mb.id) {
return (
<div key={mb.id}>
<div className="flex items-center gap-2 py-2 px-3" style={{ paddingLeft: 12 + depth * 16 }}>
<Icon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(mb.id);
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
<button
onClick={() => handleRename(mb.id)}
disabled={isLoading || !editingName.trim()}
className="p-1.5 text-primary hover:bg-accent rounded-md disabled:opacity-50"
title={t('rename')}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={cancelEdit}
className="p-1.5 text-muted-foreground hover:bg-accent rounded-md"
title={t('cancel')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
if (deletingId === mb.id) {
return (
<div key={mb.id}>
<div className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20" style={{ marginLeft: depth * 16 }}>
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_delete', { name: mb.name })}
</p>
<button
onClick={() => handleDelete(mb.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
</div>
);
}
return (
<div key={mb.id}>
<div
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50"
style={{ paddingLeft: 12 + depth * 16 }}
>
<div className="flex items-center gap-2.5 min-w-0">
{/* Expand/collapse toggle for folders with children */}
{hasChildren ? (
<button
onClick={() => toggleExpanded(mb.id)}
className="p-0.5 text-muted-foreground hover:text-foreground rounded transition-colors flex-shrink-0"
>
{isExpanded
? <ChevronDown className="w-3.5 h-3.5" />
: <ChevronRight className="w-3.5 h-3.5" />
}
</button>
) : (
<span className="w-4.5 flex-shrink-0" />
)}
<div className="relative flex-shrink-0">
<button
onClick={() => setIconPickerId(iconPickerId === mb.id ? null : mb.id)}
className={cn(
"p-1 rounded-md transition-colors",
iconPickerId === mb.id
? "bg-accent"
: "hover:bg-accent"
)}
title={t('change_icon')}
>
<Icon className={cn(
"w-4 h-4",
mb.role ? "text-primary" : "text-muted-foreground"
)} />
</button>
{iconPickerId === mb.id && (
<IconPicker
currentIcon={getIconName(mb)}
onSelect={(iconName) => {
setFolderIcon(mb.id, iconName);
setIconPickerId(null);
}}
onClose={() => setIconPickerId(null)}
/>
)}
</div>
<span className="text-sm text-foreground truncate">{mb.name}</span>
{mb.role && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
{t(`role_${mb.role}`)}
</span>
)}
{mb.unreadEmails > 0 && (
<span className="text-xs tabular-nums px-1.5 py-0.5 rounded-full bg-primary text-primary-foreground font-medium flex-shrink-0">
{mb.unreadEmails}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
{mb.myRights?.mayCreateChild && (
<button
onClick={() => startCreateSubfolder(mb.id)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title={t('create_subfolder')}
>
<FolderPlus className="w-3.5 h-3.5" />
</button>
)}
{mb.myRights?.mayRename && (
<button
onClick={() => startEdit(mb)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title={t('rename')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{mb.myRights?.mayDelete && !mb.role && (
<button
onClick={() => setDeletingId(mb.id)}
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title={t('delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Inline subfolder creation */}
{renderCreateInline(mb.id, depth + 1)}
{/* Render children if expanded */}
{hasChildren && isExpanded && (
<div>
{node.children.map(child => renderFolderNode(child))}
</div>
)}
</div>
);
};
return (
<div className="space-y-8">
{/* Folder List — primary section */}
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
<div className="space-y-0.5">
{folderTree.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Folder className="w-10 h-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">{t('no_folders')}</p>
</div>
) : (
folderTree.map(node => renderFolderNode(node))
)}
</div>
{/* Create top-level folder */}
{renderCreateInline(null, 0)}
{!isCreating && (
<button
onClick={() => { setIsCreating(true); setCreatingParentId(null); setNewFolderName(''); }}
className="flex items-center gap-2 mt-3 px-3 py-2 text-sm text-primary hover:bg-primary/5 rounded-md transition-colors w-full border border-dashed border-primary/30 hover:border-primary/50"
>
<Plus className="w-4 h-4" />
{t('create_folder')}
</button>
)}
</SettingsSection>
{/* Standard Folder Roles — advanced section */}
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
{STANDARD_ROLES.map((role) => (
<SettingItem key={role} label={t(`role_${role}`)}>
<Select
value={getRoleMailboxId(role)}
onChange={(value) => handleRoleChange(role, value)}
options={[
{ value: '', label: t('role_none') },
...ownMailboxes.map(mb => ({
value: mb.id,
label: mb.name,
})),
]}
/>
</SettingItem>
))}
</SettingsSection>
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import { SettingsSection } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
function KeywordColorPicker({
value,
onChange,
}: {
value: string;
onChange: (color: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{PALETTE_KEYS.map((colorKey) => (
<button
key={colorKey}
type="button"
onClick={() => onChange(colorKey)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
KEYWORD_PALETTE[colorKey].dot,
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
aria-label={colorKey}
/>
))}
</div>
);
}
function KeywordRow({
keyword,
onEdit,
onDelete,
}: {
keyword: KeywordDefinition;
onEdit: () => void;
onDelete: () => void;
}) {
const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color];
return (
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group">
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={onEdit}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={onDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t("delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
function KeywordEditForm({
initial,
existingIds,
onSave,
onCancel,
}: {
initial?: KeywordDefinition;
existingIds: string[];
onSave: (keyword: KeywordDefinition) => void;
onCancel: () => void;
}) {
const t = useTranslations("settings.keywords");
const [label, setLabel] = useState(initial?.label || "");
const [color, setColor] = useState(initial?.color || "blue");
const isEditing = !!initial;
const normalizedId = isEditing
? initial.id
: label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const isDuplicate = !isEditing && normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const handleSave = () => {
if (!isValid) return;
onSave({ id: normalizedId, label: label.trim(), color });
};
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("label_field")}
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={t("label_placeholder")}
autoFocus
maxLength={30}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{isDuplicate && (
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
)}
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t("color_field")}
</label>
<KeywordColorPicker value={color} onChange={setColor} />
</div>
<div className="flex items-center gap-2 justify-end">
<button
type="button"
onClick={onCancel}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors"
>
<X className="w-3.5 h-3.5" />
{t("cancel")}
</button>
<button
type="button"
onClick={handleSave}
disabled={!isValid}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Check className="w-3.5 h-3.5" />
{isEditing ? t("save") : t("add")}
</button>
</div>
</div>
);
}
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } =
useSettingsStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const existingIds = emailKeywords.map((k) => k.id);
const handleAdd = (keyword: KeywordDefinition) => {
addKeyword(keyword);
setIsAdding(false);
};
const handleEdit = (keyword: KeywordDefinition) => {
updateKeyword(keyword.id, { label: keyword.label, color: keyword.color });
setEditingId(null);
};
const handleDelete = (id: string) => {
removeKeyword(id);
};
const handleResetDefaults = () => {
reorderKeywords(DEFAULT_KEYWORDS);
};
return (
<SettingsSection title={t("title")} description={t("description")}>
<div className="space-y-2">
{emailKeywords.map((keyword) =>
editingId === keyword.id ? (
<KeywordEditForm
key={keyword.id}
initial={keyword}
existingIds={existingIds.filter((id) => id !== keyword.id)}
onSave={handleEdit}
onCancel={() => setEditingId(null)}
/>
) : (
<KeywordRow
key={keyword.id}
keyword={keyword}
onEdit={() => {
setEditingId(keyword.id);
setIsAdding(false);
}}
onDelete={() => handleDelete(keyword.id)}
/>
)
)}
{isAdding ? (
<KeywordEditForm
existingIds={existingIds}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
) : (
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={() => {
setIsAdding(true);
setEditingId(null);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-dashed border-border hover:border-primary hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
>
<Plus className="w-3.5 h-3.5" />
{t("add_keyword")}
</button>
<button
type="button"
onClick={handleResetDefaults}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("reset_defaults")}
</button>
</div>
)}
</div>
</SettingsSection>
);
}
+18 -19
View File
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface SettingsSectionProps {
title: string;
@@ -54,17 +55,17 @@ export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps)
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
${checked ? 'bg-primary' : 'bg-muted'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-150',
checked ? 'bg-primary' : 'bg-muted',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
)}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-background transition-transform
${checked ? 'translate-x-6' : 'translate-x-1'}
`}
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-150',
checked ? 'translate-x-6' : 'translate-x-1'
)}
/>
</button>
);
@@ -78,20 +79,18 @@ interface RadioGroupProps {
export function RadioGroup({ value, onChange, options }: RadioGroupProps) {
return (
<div className="flex gap-2">
<div className="flex gap-1.5">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`
px-3 py-1.5 text-xs rounded transition-colors
${
value === option.value
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-foreground'
}
`}
className={cn(
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150',
value === option.value
? 'bg-primary text-primary-foreground font-medium'
: 'bg-muted hover:bg-accent text-foreground'
)}
>
{option.label}
</button>
@@ -112,7 +111,7 @@ export function Select({ value, onChange, options }: SelectProps) {
value={value}
onChange={(e) => onChange(e.target.value)}
dir="auto"
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
+4 -4
View File
@@ -169,7 +169,7 @@ export function VacationSettings() {
type="datetime-local"
value={localFromDate ? utcToLocalDatetime(localFromDate) : ''}
onChange={(e) => setLocalFromDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
/>
</SettingItem>
<SettingItem
@@ -180,7 +180,7 @@ export function VacationSettings() {
type="datetime-local"
value={localToDate ? utcToLocalDatetime(localToDate) : ''}
onChange={(e) => setLocalToDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
/>
</SettingItem>
</SettingsSection>
@@ -195,7 +195,7 @@ export function VacationSettings() {
value={localSubject}
onChange={(e) => setLocalSubject(e.target.value)}
placeholder={t('message.subject_placeholder')}
className="w-64 px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
/>
</SettingItem>
<div className="py-3">
@@ -211,7 +211,7 @@ export function VacationSettings() {
onChange={(e) => setLocalTextBody(e.target.value)}
placeholder={t('message.body_placeholder')}
rows={6}
className="w-full px-3 py-2 text-sm rounded bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
/>
</div>
</SettingsSection>

Some files were not shown because too many files have changed in this diff Show More