Compare commits
155
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477fc8c885 | ||
|
|
84abfe79dd | ||
|
|
8a92d53125 | ||
|
|
6fe4a98b02 | ||
|
|
3186198fad | ||
|
|
5c933a595f | ||
|
|
828ad4df72 | ||
|
|
f720cb3ef8 | ||
|
|
818a02428b | ||
|
|
6ed30059e5 | ||
|
|
a2cb2b2c86 | ||
|
|
1f7cd61fc7 | ||
|
|
070eeeecee | ||
|
|
de35d1d8e8 | ||
|
|
7c5785e9e8 | ||
|
|
54f4d37595 | ||
|
|
71cd826c6f | ||
|
|
e96b9a72e7 | ||
|
|
ce4ebb3dc2 | ||
|
|
6680b65863 | ||
|
|
df64959e60 | ||
|
|
8641275bb4 | ||
|
|
6e78f2a09e | ||
|
|
d429652f72 | ||
|
|
a834528bc1 | ||
|
|
7cf79d8775 | ||
|
|
dc3e5b7706 | ||
|
|
d86d109725 | ||
|
|
0f5d030d5f | ||
|
|
cde1d61d02 | ||
|
|
4b262a2746 | ||
|
|
119bfed1eb | ||
|
|
3cd123778e | ||
|
|
f2f3e1d85c | ||
|
|
f7a38a22ee | ||
|
|
e759056d5f | ||
|
|
1b35c9e3d6 | ||
|
|
89488e6fc9 | ||
|
|
7af16f2de0 | ||
|
|
ed4be96706 | ||
|
|
8044c60905 | ||
|
|
8d0c525e68 | ||
|
|
800499f54a | ||
|
|
e02477323f | ||
|
|
37b0bacccb | ||
|
|
716db8d003 | ||
|
|
73d6e581b7 | ||
|
|
f4d34477a6 | ||
|
|
26b38982ac | ||
|
|
9bffb72338 | ||
|
|
0965fbc7c1 | ||
|
|
d6cf195bfc | ||
|
|
0080d27b3e | ||
|
|
5643170220 | ||
|
|
e87e35cc48 | ||
|
|
b7fa25c314 | ||
|
|
c24fabe977 | ||
|
|
1f1db8fac8 | ||
|
|
cb74e3bf73 | ||
|
|
05f02402e3 | ||
|
|
5accf0a86c | ||
|
|
8c5e3ee240 | ||
|
|
caed067cda | ||
|
|
f149501dff | ||
|
|
a9a8710909 | ||
|
|
d1478fa46b | ||
|
|
d5404de224 | ||
|
|
838de8e5da | ||
|
|
86673a7b45 | ||
|
|
f7bbab5e4a | ||
|
|
f995558bf5 | ||
|
|
f1f31df0af | ||
|
|
c024d89477 | ||
|
|
1936a37f28 | ||
|
|
98cb3e158b | ||
|
|
85b5b3c4f1 | ||
|
|
6fc27804d6 | ||
|
|
4a90cf4fa0 | ||
|
|
528515611e | ||
|
|
6c4acdc655 | ||
|
|
202a75db0c | ||
|
|
d7b99ac95c | ||
|
|
596ebecfbb | ||
|
|
0ea79f29db | ||
|
|
4396bc9943 | ||
|
|
2fe1b01d4d | ||
|
|
d49dcb52e6 | ||
|
|
81c0a8a971 | ||
|
|
56f4195ab5 | ||
|
|
099434a125 | ||
|
|
0cd928d1fe | ||
|
|
04e8b023de | ||
|
|
ecfb197c82 | ||
|
|
9438cf75ab | ||
|
|
091c10d828 | ||
|
|
02808fa4c3 | ||
|
|
82a790de0f | ||
|
|
e85d0b946e | ||
|
|
02344d169c | ||
|
|
1267efa307 | ||
|
|
5a679275ca | ||
|
|
25f0088957 | ||
|
|
18485951c7 | ||
|
|
5e2329b55f | ||
|
|
46a80f3773 | ||
|
|
21e6fd9078 | ||
|
|
2a48740ebd | ||
|
|
8509550812 | ||
|
|
60a6ede063 | ||
|
|
eb6e73191f | ||
|
|
af64c96b9f | ||
|
|
b090bf6d52 | ||
|
|
ae29e64197 | ||
|
|
260ea7088d | ||
|
|
db8e827f68 | ||
|
|
6f0d47cbd9 | ||
|
|
2a73adf588 | ||
|
|
7fedcb8e58 | ||
|
|
4737f2928b | ||
|
|
d1d2a1dc53 | ||
|
|
3efb292c3b | ||
|
|
bb40326809 | ||
|
|
d8b0f013e8 | ||
|
|
f751274e41 | ||
|
|
2249741c61 | ||
|
|
ab72fc06ff | ||
|
|
68d4a9a641 | ||
|
|
5598c436f2 | ||
|
|
256ff7698d | ||
|
|
7848c90625 | ||
|
|
f996ab77b8 | ||
|
|
a88b4e4407 | ||
|
|
cc8195209b | ||
|
|
288205fbac | ||
|
|
15dbb3d349 | ||
|
|
5f539161d5 | ||
|
|
d3a5b4189e | ||
|
|
e5c1cd45c5 | ||
|
|
454d51e283 | ||
|
|
ffeb534740 | ||
|
|
61459c4923 | ||
|
|
d23e9a642c | ||
|
|
723aff38f2 | ||
|
|
a8a1ad1d54 | ||
|
|
cf5f271be2 | ||
|
|
d09022378b | ||
|
|
4576d37c7a | ||
|
|
40e7fa3776 | ||
|
|
c1956bfb2d | ||
|
|
c7485f532c | ||
|
|
fed508d5b7 | ||
|
|
8985865843 | ||
|
|
d41718768b | ||
|
|
481d566eb6 | ||
|
|
cf02f587de |
@@ -4,6 +4,7 @@ node_modules
|
||||
.gitignore
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.dev.example
|
||||
.claude/
|
||||
scripts/
|
||||
TODO.md
|
||||
|
||||
@@ -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
|
||||
+117
-20
@@ -1,42 +1,139 @@
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
# Server Listen Address
|
||||
# =============================================================================
|
||||
|
||||
# Hostname the server binds to (default: 0.0.0.0)
|
||||
# Set to "::" for IPv6 or "[::]" for dual-stack support.
|
||||
# HOSTNAME=0.0.0.0
|
||||
|
||||
# Port the server listens on (default: 3000)
|
||||
# PORT=3000
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
# Branding (all optional)
|
||||
# =============================================================================
|
||||
# When running with Docker, set these in .env.local:
|
||||
# APP_NAME=My Webmail
|
||||
# JMAP_SERVER_URL=https://mail.example.com
|
||||
|
||||
# Custom favicon for the browser tab.
|
||||
# Supported formats: SVG (recommended), PNG, ICO.
|
||||
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
|
||||
# Can be an absolute URL or a path relative to the public/ directory.
|
||||
# Defaults to the Bulwark favicon if not set.
|
||||
# FAVICON_URL=/branding/my-favicon.svg
|
||||
|
||||
# Custom logos for the sidebar (shown in the main app after login).
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 24×24px, max 128×128px
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# If not set, no logo is shown in the sidebar.
|
||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||
|
||||
# Custom logo images for the login page.
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 32×32px, max 512×512px
|
||||
# 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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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/
|
||||
+125
-1
@@ -1,12 +1,136 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.0 (2026-03-17)
|
||||
|
||||
### Features
|
||||
|
||||
- **Security**: Add S/MIME certificate management with identity bindings, signer auto-import, unlock controls, and compose/viewer sign, encrypt, decrypt, and verification flows
|
||||
- **Email**: Add TNEF (`winmail.dat`) parsing to extract message bodies and attachments from Outlook rich-text emails
|
||||
- **Email**: Add archive organization modes for archiving directly or into year/month subfolders
|
||||
- **Email**: Add an "Always Show Emails in Light Mode" preference to avoid dark-mode conversion issues
|
||||
- **Email**: Apply the 12-hour or 24-hour time format preference consistently across calendar and email surfaces
|
||||
- **Identity**: Add identity refresh behavior in the identity manager so server-side changes stay in sync after edits
|
||||
- **UI**: Add configurable sidebar apps with custom icons plus inline or new-tab launch modes
|
||||
- **Branding**: Add runtime branding options for custom favicon, sidebar logos, and login logos
|
||||
- **Deployment**: Add configurable server listen address support via `HOSTNAME`, including IPv6 and dual-stack guidance
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Improve all-day event handling
|
||||
- **Calendar**: Validate and default persisted calendar view mode values
|
||||
- **UI**: Use configured app names more consistently in metadata and login branding surfaces
|
||||
- **Docker**: Correct `HOSTNAME` formatting in the Docker Compose example
|
||||
- **Metadata**: Correct package author and container vendor metadata
|
||||
|
||||
## 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
@@ -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!
|
||||
|
||||
+5
-5
@@ -8,12 +8,12 @@ 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.vendor="root.cloud"
|
||||
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="rbm.systems"
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -1,181 +1,265 @@
|
||||
# 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="480" />
|
||||
</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)
|
||||
[](CHANGELOG.md)
|
||||
[](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
|
||||
- **Archive modes** - archive directly or organize archived mail by year or month
|
||||
- **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
|
||||
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||
|
||||
### 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
|
||||
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
||||
- **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
|
||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||
- **Sidebar apps** - pin custom tools to the navigation rail and open them inline or in a new tab
|
||||
- **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 refresh** - keep the identity manager aligned with server-side changes after edits
|
||||
- **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)
|
||||
<details>
|
||||
<summary>Server Listen Address</summary>
|
||||
|
||||
To enable SSO login alongside Basic Auth:
|
||||
```env
|
||||
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||
PORT=3000 # Default listen port
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OAuth2/OIDC (SSO)</summary>
|
||||
|
||||
```env
|
||||
OAUTH_ENABLED=true
|
||||
@@ -184,118 +268,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 +321,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.
|
||||
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
# JMAP Webmail - Roadmap
|
||||
|
||||
This document tracks the development status and planned features for JMAP 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
|
||||
- [x] Logout functionality
|
||||
- [x] Authentication error handling
|
||||
- [x] JMAP identities for sender address
|
||||
- [x] TOTP two-factor authentication (Stalwart-compatible)
|
||||
- [x] OAuth2/OIDC with PKCE (opt-in SSO, session persistence, RP-initiated logout)
|
||||
- [x] External IdP support via explicit issuer URL (Keycloak, Authentik, etc.)
|
||||
- [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
|
||||
- [x] Session URL origin rewriting (fixes Docker/reverse proxy deployments where server returns internal hostname)
|
||||
- [x] Storage quota display
|
||||
- [x] Server capability detection
|
||||
- [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
|
||||
- [x] Draft auto-save with discard confirmation
|
||||
- [x] Mark as read/unread
|
||||
- [x] Star/unstar emails
|
||||
- [x] Delete and archive
|
||||
- [x] Color tags/labels
|
||||
- [x] Full-text search
|
||||
- [x] Advanced search with JMAP filter panel, search chips, and cross-mailbox queries
|
||||
- [x] Attachment upload and download
|
||||
- [x] Batch operations (multi-select)
|
||||
- [x] Quick reply form
|
||||
- [x] Email threading (Gmail-style inline expansion)
|
||||
|
||||
### Real-time Updates
|
||||
- [x] EventSource for JMAP push notifications
|
||||
- [x] State synchronization
|
||||
- [x] Email arrival notifications
|
||||
- [x] Real-time unread counts
|
||||
- [x] Mailbox change handling
|
||||
|
||||
### User Interface
|
||||
- [x] Three-pane layout (sidebar, list, viewer)
|
||||
- [x] Minimalist design system
|
||||
- [x] Dark and light theme support
|
||||
- [x] Custom scrollbars
|
||||
- [x] Mobile responsive design
|
||||
- [x] Keyboard shortcuts
|
||||
- [x] Drag-and-drop email organization
|
||||
- [x] Right-click context menus
|
||||
- [x] Hierarchical mailbox display
|
||||
- [x] Email list with avatars and visual hierarchy
|
||||
- [x] Expandable email headers
|
||||
- [x] External content warning banner
|
||||
- [x] SPF/DKIM/DMARC status indicators
|
||||
- [x] Loading states and skeletons
|
||||
- [x] Smooth transitions and animations
|
||||
- [x] Infinite scroll pagination
|
||||
- [x] Virtual scrolling for large email lists
|
||||
- [x] Error boundaries
|
||||
- [x] Settings page with preferences
|
||||
- [x] Navigation rail (desktop vertical icon sidebar + mobile bottom tab bar)
|
||||
- [x] Welcome banner for first-time users (one-time display, localStorage persistence)
|
||||
- [x] Confirmation dialog component with promise-based useConfirmDialog hook
|
||||
- [x] Toast notifications with undo action support and typed durations
|
||||
- [x] Inline form validation with shake animation (email composer, contact form)
|
||||
- [x] Login UX polish (error shake, discreet 2FA toggle, password visibility toggle, session expired banner)
|
||||
- [x] Empty state patterns for contacts (distinct "no data" vs "no search results" with contextual actions)
|
||||
- [x] WCAG AA reduced-motion media query (global animation/transition reset)
|
||||
- [x] Safe area inset utilities for notched devices
|
||||
- [x] Screen reader live region announcements (sr-only)
|
||||
|
||||
### Internationalization
|
||||
- [x] English language support
|
||||
- [x] French language support
|
||||
- [x] Japanese language support
|
||||
- [x] Spanish language support
|
||||
- [x] Italian language support
|
||||
- [x] German language support
|
||||
- [x] Dutch language support
|
||||
- [x] Portuguese language support
|
||||
- [x] Automatic browser language detection
|
||||
- [x] Language preference persistence
|
||||
|
||||
### Security & Accessibility
|
||||
- [x] External content blocked by default
|
||||
- [x] HTML sanitization with DOMPurify
|
||||
- [x] User control for loading external content
|
||||
- [x] Trusted senders list for automatic image loading
|
||||
- [x] Dark mode email readability (intelligent color transformation)
|
||||
- [x] WCAG 2.0 Level AA color contrast compliance
|
||||
- [x] Newsletter unsubscribe support (RFC 2369)
|
||||
- [x] XSS attack prevention with comprehensive validation
|
||||
- [x] CSP Report-Only headers with per-request nonce
|
||||
- [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy)
|
||||
- [x] Reusable focus trap hook (Tab cycling, Escape handling, focus restore)
|
||||
- [x] WCAG AA prefers-reduced-motion support (global animation/transition reset)
|
||||
- [x] Safe area insets for notched mobile devices
|
||||
- [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
|
||||
- [x] Identity badges in email viewer and list
|
||||
- [x] Tag suggestions based on context
|
||||
- [x] Display name included in From header (recipients see name, not just email)
|
||||
- [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
|
||||
- [x] Contact details view/edit form
|
||||
- [x] JMAP contacts sync (RFC 9553/9610 ContactCard/AddressBook)
|
||||
- [x] Email autocomplete from contacts
|
||||
- [x] Contacts integration in email composer (To/Cc/Bcc)
|
||||
- [x] Contact groups/lists management with JMAP members map
|
||||
- [x] vCard import/export (RFC 6350 parser/generator, duplicate detection)
|
||||
- [x] Bulk contact operations (multi-select, delete, group add, export)
|
||||
- [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)
|
||||
- [x] Month, week, day, and agenda views
|
||||
- [x] Event modal (create/edit/delete with recurrence, reminders)
|
||||
- [x] Mini-calendar sidebar with calendar visibility toggles
|
||||
- [x] Calendar settings (default view, week start, time format)
|
||||
- [x] Multi-day event spanning across all covered days
|
||||
- [x] Column-based overlap layout for concurrent events
|
||||
- [x] Locale-aware date formatting via next-intl
|
||||
- [x] First day of week and time format settings wired to views
|
||||
- [x] Push notification handling for calendar state changes
|
||||
- [x] Calendar page capability check (redirect if unsupported)
|
||||
- [x] Error handling with toast feedback on event CRUD
|
||||
- [x] Timezone auto-detection on event creation
|
||||
- [x] Input validation, color sanitization, focus trap
|
||||
- [x] ARIA grid roles and event card accessible labels
|
||||
- [x] Mobile touch targets (44px minimum)
|
||||
- [x] Calendar keyboard shortcuts (m/w/d/a views, t today, n new event)
|
||||
- [x] i18n support with ICU pluralization (all 8 languages)
|
||||
- [x] Drag-and-drop event rescheduling (week/day time snap, month date move)
|
||||
- [x] iCalendar (.ics) file import via CalendarEvent/parse with preview and bulk create
|
||||
- [x] Event notifications with client-side alert evaluation and toast display
|
||||
- [x] Notification sound, acknowledged alert persistence (localStorage), proactive 24h event fetch
|
||||
- [x] Configurable notification settings (enable/disable, sound toggle)
|
||||
- [x] Participant scheduling with iTIP invitations (organizer/attendee UI, RSVP buttons, contact autocomplete)
|
||||
- [x] Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import to calendar, cancellation display)
|
||||
- [x] Scheduling message support (sendSchedulingMessages flag for create/update/delete)
|
||||
- [x] Click-drag to create events (pointer-based time range selection, 15-min snap, visual overlay)
|
||||
- [x] Event resize by dragging bottom edge handle (15-min snap, optimistic JMAP update)
|
||||
- [x] Recurring event edit/delete scope dialog (this event / this and following / all events)
|
||||
- [x] Double-click quick event creation (inline title input, PT1H default)
|
||||
- [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
|
||||
- [x] Sieve generator and parser with JSON metadata round-trip
|
||||
- [x] Filter store with CRUD, reorder, toggle, auto-save with rollback
|
||||
- [x] Opaque script detection with reset to visual builder option
|
||||
- [x] Focus trap accessibility in modals
|
||||
- [x] Toast validation feedback for empty rules
|
||||
- [x] Push notification handling for SieveScript state changes
|
||||
- [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
|
||||
- [x] Template manager modal (create, edit, duplicate, delete)
|
||||
- [x] Template picker in composer toolbar with search and category filter
|
||||
- [x] Custom placeholder prompt on template insertion
|
||||
- [x] Settings tab for template management
|
||||
- [x] Keyboard shortcut (Ctrl+Shift+T to insert template)
|
||||
- [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)
|
||||
- [x] Unit tests for contact store (56 tests)
|
||||
- [x] Unit tests for JMAP contact client (41 tests)
|
||||
- [x] Unit tests for vCard parser (18 tests)
|
||||
- [x] Unit tests for thread utilities (20 tests)
|
||||
- [x] Unit tests for email headers (39 tests)
|
||||
- [x] Component tests (contacts, UI components — 41 tests)
|
||||
- [x] JMAP client method tests (identity: 20, contacts: 41)
|
||||
- [x] Unit tests for Sieve generator (50 tests)
|
||||
- [x] Unit tests for Sieve parser (14 tests)
|
||||
- [x] Unit tests for calendar alerts (36 tests)
|
||||
- [x] Unit tests for calendar notification store (8 tests)
|
||||
- [x] Unit tests for calendar invitation parsing (25 tests)
|
||||
- [x] Unit tests for calendar participants (26 tests)
|
||||
- [x] Unit tests for template utilities (48 tests)
|
||||
- [x] Unit tests for OAuth PKCE and discovery (14 tests)
|
||||
- [x] XSS attack vector testing
|
||||
- [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] 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)
|
||||
|
||||
## 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
|
||||
|
||||
- [ ] Next.js workspace root warning (cosmetic)
|
||||
|
||||
## Contributing
|
||||
|
||||
Want to help implement a feature? Check out our [CONTRIBUTING.md](CONTRIBUTING.md) guide!
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+237
-44
@@ -1,19 +1,23 @@
|
||||
"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,
|
||||
format, parseISO,
|
||||
} from "date-fns";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { isCalendarViewMode } 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 +28,14 @@ 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 { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
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,15 +52,20 @@ export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("calendar");
|
||||
const isMobile = useIsMobile();
|
||||
const { client, isAuthenticated } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
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();
|
||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||
|
||||
const currentUserEmails = useMemo(() =>
|
||||
identities.map(id => id.email).filter(Boolean),
|
||||
@@ -59,6 +74,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 +84,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,9 +123,19 @@ 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) {
|
||||
switch (normalizedViewMode) {
|
||||
case "month": {
|
||||
const ms = startOfMonth(d);
|
||||
const me = endOfMonth(d);
|
||||
@@ -118,17 +162,17 @@ export default function CalendarPage() {
|
||||
end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"),
|
||||
};
|
||||
}
|
||||
}, [selectedDate, viewMode, firstDayOfWeek]);
|
||||
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && calendars.length > 0) {
|
||||
if (client && calendars.length > 0 && dateRange) {
|
||||
fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
}, [client, calendars.length, dateRange, fetchEvents]);
|
||||
|
||||
const navigatePrev = useCallback(() => {
|
||||
let next: Date;
|
||||
switch (viewMode) {
|
||||
switch (normalizedViewMode) {
|
||||
case "month": next = subMonths(selectedDate, 1); break;
|
||||
case "week": next = subWeeks(selectedDate, 1); break;
|
||||
case "day": next = subDays(selectedDate, 1); break;
|
||||
@@ -136,11 +180,11 @@ export default function CalendarPage() {
|
||||
}
|
||||
setSelectedDate(next);
|
||||
setMiniMonth(next);
|
||||
}, [viewMode, selectedDate, setSelectedDate]);
|
||||
}, [normalizedViewMode, selectedDate, setSelectedDate]);
|
||||
|
||||
const navigateNext = useCallback(() => {
|
||||
let next: Date;
|
||||
switch (viewMode) {
|
||||
switch (normalizedViewMode) {
|
||||
case "month": next = addMonths(selectedDate, 1); break;
|
||||
case "week": next = addWeeks(selectedDate, 1); break;
|
||||
case "day": next = addDays(selectedDate, 1); break;
|
||||
@@ -148,17 +192,42 @@ export default function CalendarPage() {
|
||||
}
|
||||
setSelectedDate(next);
|
||||
setMiniMonth(next);
|
||||
}, [viewMode, selectedDate, setSelectedDate]);
|
||||
}, [normalizedViewMode, selectedDate, setSelectedDate]);
|
||||
|
||||
const goToToday = useCallback(() => {
|
||||
setSelectedDate(new Date());
|
||||
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 && normalizedViewMode === "month") {
|
||||
setViewMode("day");
|
||||
}
|
||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||
|
||||
const handleMiniMonthChange = useCallback((date: Date) => {
|
||||
setMiniMonth(date);
|
||||
@@ -178,16 +247,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;
|
||||
@@ -539,7 +627,7 @@ export default function CalendarPage() {
|
||||
}
|
||||
|
||||
const viewContent = (() => {
|
||||
switch (viewMode) {
|
||||
switch (normalizedViewMode) {
|
||||
case "month":
|
||||
return (
|
||||
<CalendarMonthView
|
||||
@@ -548,7 +636,10 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
case "week":
|
||||
@@ -559,9 +650,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 +665,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 +679,8 @@ export default function CalendarPage() {
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
);
|
||||
@@ -601,54 +700,136 @@ 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'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Sidebar - full height */}
|
||||
{!isMobile && !inlineApp && (
|
||||
<>
|
||||
<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"); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!inlineApp && (
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<CalendarToolbar
|
||||
selectedDate={selectedDate}
|
||||
viewMode={viewMode}
|
||||
viewMode={normalizedViewMode}
|
||||
onPrev={navigatePrev}
|
||||
onNext={navigateNext}
|
||||
onToday={goToToday}
|
||||
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 */}
|
||||
{isMobile && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailEvent && detailAnchorRect && (
|
||||
<EventDetailPopover
|
||||
@@ -661,12 +842,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 +862,7 @@ export default function CalendarPage() {
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -689,6 +874,14 @@ export default function CalendarPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
onClose={() => setShowSubscriptionModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
actionType={pendingScopeAction?.type || "edit"}
|
||||
|
||||
+198
-144
@@ -3,23 +3,27 @@
|
||||
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 { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
|
||||
@@ -31,23 +35,23 @@ 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 { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
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 +66,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 +121,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 +217,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 +226,7 @@ export default function ContactsPage() {
|
||||
|
||||
const handleSelectGroup = (id: string) => {
|
||||
setSelectedGroupId(id);
|
||||
setActiveCategory({ groupId: id });
|
||||
setView("group-detail");
|
||||
};
|
||||
|
||||
@@ -215,11 +265,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 +278,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 +352,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 +378,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 +408,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,134 +456,154 @@ 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'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</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")}
|
||||
>
|
||||
<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>
|
||||
</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"
|
||||
/>
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
{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"); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
{/* 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}
|
||||
>
|
||||
<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>
|
||||
|
||||
{isMobile && (
|
||||
<NavigationRail orientation="horizontal" />
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"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 { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
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 { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
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'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
<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"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+499
-239
@@ -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.4.0";
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
+697
-107
File diff suppressed because it is too large
Load Diff
+360
-44
@@ -1,9 +1,31 @@
|
||||
"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,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
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 +33,311 @@ 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 { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-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 { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
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' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | '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,
|
||||
encryption: KeyRound,
|
||||
vacation: PalmtreeIcon,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
filters: Filter,
|
||||
templates: FileText,
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
files: HardDrive,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
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 { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
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' },
|
||||
{ id: 'encryption', label: t('tabs.encryption'), icon: tabIcons.encryption, 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: 'sidebar_apps', label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' },
|
||||
{ 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 === 'encryption' && <SmimeSettings />}
|
||||
{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 === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{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"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</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"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</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'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
{!inlineApp && (
|
||||
<>
|
||||
{/* 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,50 +353,73 @@ 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>
|
||||
</>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -19,8 +19,21 @@ 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',
|
||||
faviconUrl: process.env.FAVICON_URL || '/branding/Bulwark_Favicon.svg',
|
||||
appLogoLightUrl: process.env.APP_LOGO_LIGHT_URL || '',
|
||||
appLogoDarkUrl: process.env.APP_LOGO_DARK_URL || '',
|
||||
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
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
+99
-15
@@ -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 */
|
||||
|
||||
@@ -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 |
+9
-4
@@ -14,10 +14,15 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const faviconUrl = process.env.FAVICON_URL;
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,23 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { cn, formatDateTime } 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 { useSettingsStore } from "@/stores/settings-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);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
|
||||
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 +108,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: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
"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";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Users } from "lucide-react";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
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 +35,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,16 +64,18 @@ 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);
|
||||
const startDate = parseISO(event.start);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
|
||||
const calendarName = calendar?.name || "";
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||
const timeString = `${format(startDate, "HH:mm")} – ${format(endTime, "HH:mm")}`;
|
||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
@@ -101,6 +111,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 +120,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 +134,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 +174,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 && (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
getStatusCounts,
|
||||
buildParticipantMap,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface EventModalProps {
|
||||
event?: CalendarEvent | null;
|
||||
@@ -29,6 +31,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 +60,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 +106,13 @@ export function EventModal({
|
||||
onRsvp,
|
||||
onClose,
|
||||
currentUserEmails = [],
|
||||
isMobile = false,
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
const isEdit = !!event;
|
||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||
|
||||
const userIsOrganizer = useMemo(() => {
|
||||
if (!event) return true;
|
||||
@@ -120,6 +162,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 +239,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 +265,7 @@ export function EventModal({
|
||||
description: description.trim(),
|
||||
start: startStr,
|
||||
duration,
|
||||
timeZone,
|
||||
timeZone: allDay ? null : timeZone,
|
||||
showWithoutTime: allDay,
|
||||
calendarIds: { [calendarId]: true },
|
||||
status: "confirmed",
|
||||
@@ -330,7 +379,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 +393,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 +429,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">
|
||||
@@ -401,7 +455,7 @@ export function EventModal({
|
||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, "HH:mm")} – {format(endD, "HH:mm")}
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -431,8 +485,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 +527,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, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<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 +887,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 +942,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 +950,6 @@ export function EventModal({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
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";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface ICalImportModalProps {
|
||||
@@ -20,6 +21,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");
|
||||
@@ -27,6 +29,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const tCommon = useTranslations("common");
|
||||
const tForm = useTranslations("calendar.form");
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
|
||||
const [step, setStep] = useState<ImportStep>("select");
|
||||
const [parsedEvents, setParsedEvents] = useState<Partial<CalendarEvent>[]>([]);
|
||||
@@ -38,6 +41,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 +109,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);
|
||||
@@ -139,9 +196,10 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
if (!event.start) return "";
|
||||
try {
|
||||
const date = parseISO(event.start);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
return event.showWithoutTime
|
||||
? format(date, "MMM d, yyyy")
|
||||
: format(date, "MMM d, yyyy HH:mm");
|
||||
: format(date, `MMM d, yyyy ${timeFmt}`);
|
||||
} catch {
|
||||
return event.start;
|
||||
}
|
||||
@@ -181,50 +239,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 +432,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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
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, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
import type { CertificateInfo } from "@/lib/smime/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
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");
|
||||
const smimeStore = useSmimeStore();
|
||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!contact) return;
|
||||
let cancelled = false;
|
||||
const parseCerts = async () => {
|
||||
const results = new Map<number, CertificateInfo>();
|
||||
for (let i = 0; i < cryptoKeys.length; i++) {
|
||||
const key = cryptoKeys[i];
|
||||
if (typeof key.uri !== 'string') continue;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string | null = null;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
// data URI — extract base64 content
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
// PEM-encoded certificate inline
|
||||
derBytes = key.uri;
|
||||
}
|
||||
if (!derBytes) continue;
|
||||
const cert = parseCertificatePemOrDer(derBytes);
|
||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
||||
const info = await extractCertificateInfo(cert, der);
|
||||
if (!cancelled) results.set(i, info);
|
||||
} catch { /* skip unparseable keys */ }
|
||||
}
|
||||
if (!cancelled) setParsedCerts(results);
|
||||
};
|
||||
if (cryptoKeys.length > 0) {
|
||||
parseCerts();
|
||||
} else {
|
||||
setParsedCerts(new Map());
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
@@ -35,149 +121,415 @@ 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 handleImportContactCert = async (keyIndex: number) => {
|
||||
const key = cryptoKeys[keyIndex];
|
||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) return;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
||||
toast.success(t("detail.cert_imported"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
||||
}
|
||||
};
|
||||
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">“{nicknames.map(n => n.name).join(", ")}”</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) => {
|
||||
const certInfo = parsedCerts.get(i);
|
||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||
const alreadyImported = certInfo?.emailAddresses?.[0]
|
||||
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
|
||||
{certInfo ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpired ? (
|
||||
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
|
||||
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
|
||||
<p>
|
||||
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
|
||||
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
|
||||
</p>
|
||||
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
|
||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
||||
</div>
|
||||
{!alreadyImported && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-4 mt-1"
|
||||
onClick={() => handleImportContactCert(i)}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t("detail.import_to_smime")}
|
||||
</Button>
|
||||
)}
|
||||
{alreadyImported && (
|
||||
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div 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>
|
||||
)}
|
||||
</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 +539,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
+883
-229
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
+3146
-455
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SmimeStatus } from "@/lib/smime/types";
|
||||
|
||||
interface SmimeStatusBannerProps {
|
||||
status: SmimeStatus;
|
||||
onUnlockKey?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
||||
const t = useTranslations('smime');
|
||||
|
||||
const items: Array<{
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
variant: 'success' | 'warning' | 'error' | 'info';
|
||||
}> = [];
|
||||
|
||||
// Encryption status
|
||||
if (status.isEncrypted) {
|
||||
if (status.decryptionError) {
|
||||
if (status.decryptionError === 'locked') {
|
||||
items.push({
|
||||
icon: <Lock className="w-4 h-4" />,
|
||||
text: t('unlock_key_desc'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-4 h-4" />,
|
||||
text: t('status_encrypted_failed'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push({
|
||||
icon: <LockOpen className="w-4 h-4" />,
|
||||
text: t('status_encrypted_ok'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Signature status
|
||||
if (status.isSigned) {
|
||||
if (status.signatureValid === true) {
|
||||
if (status.signerEmailMatch === false) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
text: t('status_signed_mismatch'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldCheck className="w-4 h-4" />,
|
||||
text: t('status_signed_valid'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
} else if (status.signatureValid === false) {
|
||||
items.push({
|
||||
icon: <ShieldAlert className="w-4 h-4" />,
|
||||
text: status.signatureError || t('status_signed_invalid'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported S/MIME
|
||||
if (status.unsupportedReason) {
|
||||
items.push({
|
||||
icon: <Info className="w-4 h-4" />,
|
||||
text: t('status_unsupported'),
|
||||
variant: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const variantStyles = {
|
||||
success: 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
||||
error: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800',
|
||||
info: 'bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5 py-1", className)}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm border",
|
||||
variantStyles[item.variant],
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="flex-1">{item.text}</span>
|
||||
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
||||
<button
|
||||
onClick={onUnlockKey}
|
||||
className="text-xs font-medium underline hover:no-underline"
|
||||
>
|
||||
{t('unlock_key')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,15 @@ 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 emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
||||
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 +261,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 +316,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)
|
||||
@@ -302,7 +364,7 @@ function EmailCard({
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
|
||||
if (resolvedTheme === 'dark') {
|
||||
if (resolvedTheme === 'dark' && !emailAlwaysLightMode) {
|
||||
if (htmlNode.style) {
|
||||
const originalStyles = htmlNode.style.cssText;
|
||||
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
|
||||
@@ -342,7 +404,6 @@ function EmailCard({
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
|
||||
return { html: htmlEscaped, isHtml: false };
|
||||
}
|
||||
@@ -350,11 +411,15 @@ function EmailCard({
|
||||
|
||||
// Fallback to preview
|
||||
if (email.preview) {
|
||||
return { html: email.preview.replace(/\n/g, '<br>'), isHtml: false };
|
||||
const previewHtml = email.preview
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return { html: previewHtml, isHtml: false };
|
||||
}
|
||||
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal, resolvedTheme]);
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
@@ -366,16 +431,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 +462,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,15 +514,17 @@ 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",
|
||||
"prose prose-sm max-w-none",
|
||||
!emailAlwaysLightMode && "dark:prose-invert",
|
||||
"prose-p:my-2 prose-headings:my-3",
|
||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
style={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
/>
|
||||
</div>
|
||||
@@ -465,6 +535,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 +544,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 +552,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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -33,13 +38,33 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
const tNotif = useTranslations('notifications');
|
||||
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
|
||||
const identities = useIdentityStore((state) => state.identities);
|
||||
const syncIdentities = useSyncIdentities();
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
// Re-fetch all identities from server and update stores
|
||||
const refreshIdentities = useCallback(async () => {
|
||||
if (!client) return;
|
||||
try {
|
||||
const serverIdentities = await client.getIdentities();
|
||||
const username = useAuthStore.getState().username;
|
||||
const sorted = [...serverIdentities].sort((a, b) => {
|
||||
const aMatch = a.email === username ? -1 : 0;
|
||||
const bMatch = b.email === username ? -1 : 0;
|
||||
return aMatch - bMatch;
|
||||
});
|
||||
useIdentityStore.getState().setIdentities(sorted);
|
||||
syncIdentities();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to refresh identities';
|
||||
toast.error(message);
|
||||
}
|
||||
}, [client, syncIdentities]);
|
||||
|
||||
// Focus trap with Escape handling
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
@@ -54,9 +79,10 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
// Close on click outside
|
||||
// Close on click outside (but not when ConfirmDialog is open)
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (confirmDialogProps.isOpen) return;
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
@@ -66,13 +92,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen, onClose, modalRef]);
|
||||
}, [isOpen, onClose, modalRef, confirmDialogProps.isOpen]);
|
||||
|
||||
const handleCreate = useCallback(async (data: IdentityFormData) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const newIdentity = await client.createIdentity(
|
||||
await client.createIdentity(
|
||||
data.name,
|
||||
data.email,
|
||||
data.replyTo,
|
||||
@@ -81,7 +107,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
data.htmlSignature
|
||||
);
|
||||
|
||||
addIdentity(newIdentity);
|
||||
await refreshIdentities();
|
||||
setIsCreating(false);
|
||||
toast.success(tNotif('identity_created'));
|
||||
} catch (error) {
|
||||
@@ -89,7 +115,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
toast.error(tNotif('identity_create_failed', { error: message }));
|
||||
throw error;
|
||||
}
|
||||
}, [client, addIdentity, t, tNotif]);
|
||||
}, [client, refreshIdentities, t, tNotif]);
|
||||
|
||||
const handleUpdate = useCallback(async (identity: Identity, data: IdentityFormData) => {
|
||||
if (!client) return;
|
||||
@@ -103,7 +129,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
htmlSignature: data.htmlSignature,
|
||||
});
|
||||
|
||||
updateIdentityLocal(identity.id, data);
|
||||
await refreshIdentities();
|
||||
setEditingId(null);
|
||||
toast.success(tNotif('identity_updated'));
|
||||
} catch (error) {
|
||||
@@ -111,7 +137,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
toast.error(tNotif('identity_update_failed', { error: message }));
|
||||
throw error;
|
||||
}
|
||||
}, [client, updateIdentityLocal, t, tNotif]);
|
||||
}, [client, refreshIdentities, t, tNotif]);
|
||||
|
||||
const handleDelete = useCallback(async (identity: Identity) => {
|
||||
if (!client) return;
|
||||
@@ -132,7 +158,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
try {
|
||||
await client.deleteIdentity(identity.id);
|
||||
removeIdentity(identity.id);
|
||||
await refreshIdentities();
|
||||
toast.success(tNotif('identity_deleted'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||
@@ -140,12 +166,12 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}, [client, removeIdentity, t, tNotif, confirmDialog]);
|
||||
}, [client, refreshIdentities, t, tNotif, confirmDialog]);
|
||||
|
||||
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 +193,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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
// Curated list of commonly useful icons, organized by category
|
||||
const POPULAR_ICONS = [
|
||||
// Communication
|
||||
'Globe', 'Rss', 'Radio', 'Podcast', 'MessageCircle', 'MessageSquare', 'MessagesSquare',
|
||||
'Phone', 'Video', 'Webcam', 'Headphones', 'Mic',
|
||||
// Productivity
|
||||
'FileText', 'FileSpreadsheet', 'Notebook', 'BookOpen', 'ClipboardList',
|
||||
'ListTodo', 'CheckSquare', 'SquareKanban', 'Kanban', 'Trello',
|
||||
'PenLine', 'Pencil', 'Edit', 'NotebookPen',
|
||||
// Dev / Tech
|
||||
'Code', 'Terminal', 'Braces', 'Bug', 'Database', 'Server', 'Cpu',
|
||||
'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet',
|
||||
'Wifi', 'Cloud', 'CloudDownload', 'CloudUpload',
|
||||
// Social / People
|
||||
'Users', 'UserPlus', 'UserCircle', 'Contact', 'PersonStanding',
|
||||
'Heart', 'ThumbsUp', 'Star', 'Award', 'Trophy', 'Crown',
|
||||
// Media
|
||||
'Image', 'Camera', 'Film', 'Music', 'Play', 'Tv', 'Youtube', 'Clapperboard',
|
||||
'Palette', 'Paintbrush', 'Brush',
|
||||
// Navigation / Location
|
||||
'Map', 'MapPin', 'Navigation', 'Compass', 'Home', 'Building', 'Building2',
|
||||
'Landmark', 'Store', 'Warehouse',
|
||||
// Finance
|
||||
'DollarSign', 'Euro', 'CreditCard', 'Wallet', 'Receipt', 'PiggyBank',
|
||||
'TrendingUp', 'BarChart', 'BarChart3', 'LineChart', 'PieChart',
|
||||
// Security
|
||||
'Shield', 'ShieldCheck', 'Lock', 'Unlock', 'Key', 'Fingerprint', 'Eye',
|
||||
// Science / Health
|
||||
'Beaker', 'Atom', 'Dna', 'Microscope', 'Stethoscope', 'HeartPulse', 'Pill',
|
||||
'Syringe', 'Thermometer',
|
||||
// Nature
|
||||
'Sun', 'Moon', 'CloudSun', 'Snowflake', 'Zap', 'Flame',
|
||||
'TreePine', 'Flower', 'Leaf', 'Mountain', 'Waves',
|
||||
// Tools
|
||||
'Wrench', 'Hammer', 'Scissors', 'Ruler', 'Magnet',
|
||||
'Package', 'Gift', 'Box', 'Archive',
|
||||
// Transport
|
||||
'Car', 'Bike', 'Bus', 'Train', 'Plane', 'Ship', 'Rocket',
|
||||
// Food
|
||||
'Coffee', 'Wine', 'Beer', 'Pizza', 'Apple', 'Cake', 'CookingPot',
|
||||
// Misc
|
||||
'Gamepad2', 'Dice5', 'Puzzle', 'Sparkles', 'Wand2', 'Bot', 'BrainCircuit',
|
||||
'Lightbulb', 'Bookmark', 'Flag', 'Bell', 'Clock', 'Timer',
|
||||
'Link', 'ExternalLink', 'QrCode', 'Scan', 'LayoutGrid', 'Layers',
|
||||
'Aperture', 'CircleDot', 'Target', 'Crosshair',
|
||||
];
|
||||
|
||||
interface IconPickerProps {
|
||||
value: string;
|
||||
onChange: (iconName: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconPicker({ value, onChange, className }: IconPickerProps) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get all available icon names
|
||||
const allIconNames = useMemo(() => {
|
||||
return Object.keys(lucideIcons).filter(
|
||||
k => /^[A-Z]/.test(k) && k !== 'createLucideIcon' && k !== 'Icon'
|
||||
).sort();
|
||||
}, []);
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
const source = showAll ? allIconNames : POPULAR_ICONS.filter(name => name in lucideIcons);
|
||||
if (!search.trim()) return source;
|
||||
const q = search.toLowerCase();
|
||||
return source.filter(name => name.toLowerCase().includes(q));
|
||||
}, [search, showAll, allIconNames]);
|
||||
|
||||
const renderIcon = useCallback((name: string) => {
|
||||
const IconComponent = lucideIcons[name as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent className="w-5 h-5" />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('search_icons')}
|
||||
className="pl-8 h-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className={cn(
|
||||
'text-xs px-2 py-1 rounded-md border transition-colors whitespace-nowrap',
|
||||
showAll
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'bg-muted text-muted-foreground border-border hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{showAll ? t('show_popular') : t('show_all')}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-8 gap-1 max-h-[200px] overflow-y-auto p-1 border rounded-md bg-muted/30"
|
||||
>
|
||||
{filteredIcons.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => onChange(name)}
|
||||
title={name}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-md transition-colors',
|
||||
value === name
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{renderIcon(name)}
|
||||
</button>
|
||||
))}
|
||||
{filteredIcons.length === 0 && (
|
||||
<p className="col-span-8 py-4 text-center text-xs text-muted-foreground">
|
||||
{t('no_icons_found')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { InlineAppState } from '@/hooks/use-sidebar-apps';
|
||||
|
||||
interface InlineAppViewProps {
|
||||
apps: InlineAppState[];
|
||||
activeAppId: string;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
|
||||
const activeApp = apps.find((a) => a.id === activeAppId);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-background', className)}>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
|
||||
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Iframes - active one visible, rest hidden but alive */}
|
||||
<div className="flex-1 relative">
|
||||
{apps.map((app) => (
|
||||
<iframe
|
||||
key={app.id}
|
||||
src={app.url}
|
||||
title={app.name}
|
||||
className={cn(
|
||||
'absolute inset-0 w-full h-full border-0',
|
||||
app.id !== activeAppId && 'hidden'
|
||||
)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
"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, Plus } from "lucide-react";
|
||||
import { icons as lucideIcons, type LucideIcon } 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 { useSettingsStore } from "@/stores/settings-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -20,29 +25,151 @@ interface NavigationRailProps {
|
||||
orientation?: "vertical" | "horizontal";
|
||||
collapsed?: boolean;
|
||||
className?: string;
|
||||
quota?: { used: number; total: number } | null;
|
||||
isPushConnected?: boolean;
|
||||
onLogout?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onManageApps?: () => void;
|
||||
onInlineApp?: (appId: string, url: string, name: string) => void;
|
||||
onCloseInlineApp?: () => void;
|
||||
activeAppId?: string | null;
|
||||
}
|
||||
|
||||
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,
|
||||
onManageApps,
|
||||
onInlineApp,
|
||||
onCloseInlineApp,
|
||||
activeAppId,
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||
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: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
|
||||
];
|
||||
|
||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||
|
||||
const visibleItems = navItems.filter((item) => !item.hidden);
|
||||
|
||||
const getIsActive = (href: string) => {
|
||||
if (activeAppId) return false;
|
||||
if (href === "/") {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
@@ -52,7 +179,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")}
|
||||
>
|
||||
@@ -63,6 +190,7 @@ export function NavigationRail({
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -87,53 +215,232 @@ export function NavigationRail({
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps */}
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
const isActive = activeAppId === app.id;
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onCloseInlineApp?.();
|
||||
} else if (app.openMode === 'tab') {
|
||||
window.open(app.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
onInlineApp?.(app.id, app.url, app.name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
{AppIcon ? <AppIcon className="w-5 h-5" /> : null}
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-[64px]">{app.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Manage apps button */}
|
||||
{onManageApps && (
|
||||
<button
|
||||
onClick={onManageApps}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t("add_app")}</span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
<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}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps */}
|
||||
{sidebarApps.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t",
|
||||
collapsed ? "w-8 mx-auto my-1 pt-1" : "mx-2 my-0.5 pt-0.5"
|
||||
)}
|
||||
style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }}
|
||||
/>
|
||||
)}
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
const isActive = activeAppId === app.id;
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onCloseInlineApp?.();
|
||||
} else if (app.openMode === 'tab') {
|
||||
window.open(app.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
onInlineApp?.(app.id, app.url, app.name);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
)}
|
||||
title={collapsed ? app.name : undefined}
|
||||
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
>
|
||||
{AppIcon ? <AppIcon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} /> : null}
|
||||
{!collapsed && <span className="truncate">{app.name}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Manage apps button */}
|
||||
{onManageApps && (
|
||||
<button
|
||||
onClick={onManageApps}
|
||||
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",
|
||||
: "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"
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
title={collapsed ? t(item.labelKey) : undefined}
|
||||
title={collapsed ? t("add_app") : 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>
|
||||
<Plus className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
{!collapsed && <span className="truncate">{t("add_app")}</span>}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
||||
isSettingsActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={t("settings")}
|
||||
aria-current={isSettingsActive ? "page" : undefined}
|
||||
>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</Link>
|
||||
|
||||
<div className="w-8 border-t" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||
|
||||
{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")}
|
||||
>
|
||||
<Keyboard className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{quota && quota.total > 0 && (
|
||||
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X, Plus, Pencil, Trash2, GripVertical, ExternalLink, PanelRight } from 'lucide-react';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IconPicker } from './icon-picker';
|
||||
import { useSettingsStore, type SidebarApp } from '@/stores/settings-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
interface SidebarAppFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: 'tab' | 'inline';
|
||||
}
|
||||
|
||||
function SidebarAppForm({
|
||||
app,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
app?: SidebarApp;
|
||||
onSave: (data: SidebarAppFormData) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const isEditing = !!app;
|
||||
|
||||
const [formData, setFormData] = useState<SidebarAppFormData>({
|
||||
name: app?.name || '',
|
||||
url: app?.url || '',
|
||||
icon: app?.icon || 'Globe',
|
||||
openMode: app?.openMode || 'tab',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t('name_required');
|
||||
}
|
||||
if (!formData.url.trim()) {
|
||||
newErrors.url = t('url_required');
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(formData.url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
newErrors.url = t('url_invalid');
|
||||
}
|
||||
} catch {
|
||||
newErrors.url = t('url_invalid');
|
||||
}
|
||||
}
|
||||
if (!formData.icon) {
|
||||
newErrors.icon = t('icon_required');
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const SelectedIcon = formData.icon
|
||||
? (lucideIcons[formData.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="app-name" className="block text-sm font-medium mb-1">
|
||||
{t('name_label')} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="app-name"
|
||||
type="text"
|
||||
maxLength={50}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('name_placeholder')}
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label htmlFor="app-url" className="block text-sm font-medium mb-1">
|
||||
{t('url_label')} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="app-url"
|
||||
type="url"
|
||||
maxLength={2048}
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className={errors.url ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.url && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.url}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">{t('open_mode_label')}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: 'tab' })}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors flex-1',
|
||||
formData.openMode === 'tab'
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('open_new_tab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: 'inline' })}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors flex-1',
|
||||
formData.openMode === 'inline'
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<PanelRight className="w-4 h-4" />
|
||||
{t('open_inline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Picker */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t('icon_label')} <span className="text-destructive">*</span>
|
||||
{SelectedIcon && (
|
||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||
— <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<IconPicker
|
||||
value={formData.icon}
|
||||
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||
/>
|
||||
{errors.icon && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.icon}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{isEditing ? t('update') : t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarAppsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const { sidebarApps, addSidebarApp, updateSidebarApp, removeSidebarApp } = useSettingsStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: () => {
|
||||
if (isCreating || editingId) {
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setIsCreating(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
const handleUpdate = useCallback((id: string, data: SidebarAppFormData) => {
|
||||
updateSidebarApp(id, data);
|
||||
setEditingId(null);
|
||||
}, [updateSidebarApp]);
|
||||
|
||||
const handleDelete = useCallback(async (app: SidebarApp) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t('delete_confirm_title'),
|
||||
message: t('delete_confirm', { name: app.name }),
|
||||
confirmText: t('delete'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
removeSidebarApp(app.id);
|
||||
}, [removeSidebarApp, confirmDialog, t]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sidebar-apps-modal-title"
|
||||
className={cn(
|
||||
'bg-background border border-border rounded-lg shadow-xl',
|
||||
'w-full max-w-2xl max-h-[90vh] overflow-hidden',
|
||||
'animate-in zoom-in-95 duration-200'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 id="sidebar-apps-modal-title" className="text-lg font-semibold text-foreground">
|
||||
{t('modal_title')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{/* Create form */}
|
||||
{isCreating && (
|
||||
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('add_new')}</h3>
|
||||
<SidebarAppForm
|
||||
onSave={handleCreate}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add button */}
|
||||
{!isCreating && !editingId && (
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="mb-6 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('add_new')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Apps list */}
|
||||
<div className="space-y-3">
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
|
||||
if (editingId === app.id) {
|
||||
return (
|
||||
<div key={app.id} className="p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('edit_app')}</h3>
|
||||
<SidebarAppForm
|
||||
app={app}
|
||||
onSave={(data) => handleUpdate(app.id, data)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className="flex items-center gap-3 p-3 border border-border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-md bg-muted">
|
||||
{AppIcon ? <AppIcon className="w-5 h-5 text-muted-foreground" /> : null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{app.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{app.url}</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded-full font-medium',
|
||||
app.openMode === 'inline'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
|
||||
)}>
|
||||
{app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(app.id)}
|
||||
disabled={!!editingId || isCreating}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(app)}
|
||||
disabled={!!editingId || isCreating}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{sidebarApps.length === 0 && !isCreating && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Plus className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('no_apps')}</p>
|
||||
<p className="text-xs mt-1">{t('no_apps_hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+275
-199
@@ -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,34 @@ 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";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
|
||||
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 +87,7 @@ function MailboxTreeItem({
|
||||
onMailboxSelect,
|
||||
onToggleExpand,
|
||||
isCollapsed,
|
||||
onUnreadFilterClick,
|
||||
}: {
|
||||
node: MailboxNode;
|
||||
selectedMailbox: string;
|
||||
@@ -94,6 +95,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 +130,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 +144,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 +169,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 +191,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 +238,7 @@ function MailboxTreeItem({
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -218,6 +247,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 +350,32 @@ 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 { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
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 +409,6 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim() && onSearch) {
|
||||
onSearch(searchQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const mailboxTree = buildMailboxTree(mailboxes);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -407,12 +448,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"
|
||||
@@ -423,59 +464,42 @@ export function Sidebar({
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{(() => {
|
||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||
return logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className={cn("object-contain flex-shrink-0", isCollapsed ? "w-6 h-6" : "w-6 h-6")}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<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 +513,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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,613 @@
|
||||
"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, formatDateTime } from '@/lib/utils';
|
||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
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 timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
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: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,20 +3,29 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { ArchiveMode } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
|
||||
|
||||
export function EmailSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const [showTrustedModal, setShowTrustedModal] = useState(false);
|
||||
const [isReorganizing, setIsReorganizing] = useState(false);
|
||||
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
markAsReadDelay,
|
||||
deleteAction,
|
||||
permanentlyDeleteJunk,
|
||||
showPreview,
|
||||
emailsPerPage,
|
||||
externalContentPolicy,
|
||||
mailAttachmentAction,
|
||||
emailAlwaysLightMode,
|
||||
archiveMode,
|
||||
trustedSenders,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
@@ -29,6 +38,69 @@ export function EmailSettings() {
|
||||
return t('trusted_senders.count_other', { count });
|
||||
};
|
||||
|
||||
const handleReorganizeArchive = async () => {
|
||||
const { client } = useAuthStore.getState();
|
||||
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
||||
if (!client) return;
|
||||
|
||||
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
|
||||
if (!archiveMailbox) return;
|
||||
|
||||
setIsReorganizing(true);
|
||||
setReorganizeResult(null);
|
||||
|
||||
try {
|
||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||
|
||||
// Fetch all emails in the root archive mailbox
|
||||
const emails = await client.getEmailsInMailbox(archiveId);
|
||||
let movedCount = 0;
|
||||
|
||||
for (const email of emails) {
|
||||
const emailDate = new Date(email.receivedAt);
|
||||
const year = emailDate.getFullYear().toString();
|
||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
|
||||
// Re-read mailboxes from store each iteration in case new ones were created
|
||||
let currentMailboxes = useEmailStore.getState().mailboxes;
|
||||
|
||||
// Find or create year subfolder
|
||||
let yearMailbox = currentMailboxes.find(
|
||||
m => m.name === year && m.parentId === archiveId
|
||||
);
|
||||
if (!yearMailbox) {
|
||||
yearMailbox = await client.createMailbox(year, archiveId);
|
||||
await fetchMailboxes(client);
|
||||
currentMailboxes = useEmailStore.getState().mailboxes;
|
||||
}
|
||||
|
||||
if (archiveMode === 'year') {
|
||||
await client.moveEmail(email.id, yearMailbox.id);
|
||||
movedCount++;
|
||||
} else {
|
||||
// month mode
|
||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||
let monthMailbox = currentMailboxes.find(
|
||||
m => m.name === month && m.parentId === yearId
|
||||
);
|
||||
if (!monthMailbox) {
|
||||
monthMailbox = await client.createMailbox(month, yearId);
|
||||
await fetchMailboxes(client);
|
||||
}
|
||||
await client.moveEmail(email.id, monthMailbox.id);
|
||||
movedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setReorganizeResult(t('archive_mode.reorganize_success', { count: movedCount }));
|
||||
} catch (error) {
|
||||
console.error('Failed to reorganize archive:', error);
|
||||
setReorganizeResult(t('archive_mode.reorganize_error'));
|
||||
} finally {
|
||||
setIsReorganizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Mark as Read */}
|
||||
@@ -47,13 +119,63 @@ 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>
|
||||
|
||||
{/* Archive Mode */}
|
||||
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={archiveMode}
|
||||
onChange={(value) => updateSetting('archiveMode', value as ArchiveMode)}
|
||||
options={[
|
||||
{ value: 'single', label: t('archive_mode.single') },
|
||||
{ value: 'year', label: t('archive_mode.year') },
|
||||
{ value: 'month', label: t('archive_mode.month') },
|
||||
]}
|
||||
/>
|
||||
{archiveMode !== 'single' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleReorganizeArchive}
|
||||
disabled={isReorganizing}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
{isReorganizing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<FolderSync className="w-4 h-4" />
|
||||
)}
|
||||
<span>{t('archive_mode.reorganize')}</span>
|
||||
</button>
|
||||
{reorganizeResult && (
|
||||
<p className="text-xs text-muted-foreground">{reorganizeResult}</p>
|
||||
)}
|
||||
</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 +184,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
|
||||
@@ -75,6 +208,14 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Always Light Mode for Emails */}
|
||||
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
|
||||
<ToggleSwitch
|
||||
checked={emailAlwaysLightMode}
|
||||
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* External Content */}
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')}>
|
||||
<Select
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user