Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27451807db | ||
|
|
55099bdcbb | ||
|
|
71eb720065 | ||
|
|
8abb0c8717 | ||
|
|
3043639d2d | ||
|
|
e9b3eacbb7 | ||
|
|
f37e55e285 | ||
|
|
c0af2dbdd1 | ||
|
|
ae517732f7 | ||
|
|
3e1de10213 | ||
|
|
511740bb6d | ||
|
|
4a24d2a11d | ||
|
|
aadf56c27b | ||
|
|
9f8588eadc | ||
|
|
d657aec391 | ||
|
|
e683c90404 | ||
|
|
cfb4a23c9d | ||
|
|
fe1d4861bb | ||
|
|
5aa9b1d5f9 | ||
|
|
0913dbd3e4 | ||
|
|
29197ea355 | ||
|
|
4788e8a91a | ||
|
|
b80678b00f | ||
|
|
4f7c9c332b | ||
|
|
da103ff06f | ||
|
|
2111c77870 | ||
|
|
e5083ec1df | ||
|
|
df8d04e233 | ||
|
|
9a11a18a44 | ||
|
|
ce9f7af330 | ||
|
|
081e8a0310 | ||
|
|
6c3529b368 | ||
|
|
077a4f03a7 | ||
|
|
b04dfaf252 | ||
|
|
9c7452e7fd | ||
|
|
ec5593f567 | ||
|
|
c30c38a7af | ||
|
|
7494fc1776 | ||
|
|
16fe92d2e9 | ||
|
|
3544e6a9e7 | ||
|
|
dd1f3e11e6 | ||
|
|
6503482b55 | ||
|
|
cab57f6cd7 | ||
|
|
27f4fbdce4 | ||
|
|
39a228b20e | ||
|
|
4b069808d6 | ||
|
|
468851ff25 | ||
|
|
b7374570c8 | ||
|
|
3c9fa5dc25 | ||
|
|
3ade1c6473 | ||
|
|
1810a474a2 | ||
|
|
40982bc37b | ||
|
|
76f6149841 | ||
|
|
7d6a3c8c76 | ||
|
|
361ad49f5f | ||
|
|
f9aa5cbaee | ||
|
|
3f36045990 | ||
|
|
9a44babcf1 | ||
|
|
f032758303 | ||
|
|
92c7f74420 | ||
|
|
c2e4518cfa | ||
|
|
00f33afdf9 | ||
|
|
e566cfe687 | ||
|
|
6b7c849332 | ||
|
|
30c4afb977 | ||
|
|
1f60671886 | ||
|
|
794001fdbd | ||
|
|
9ad2facad3 | ||
|
|
89d8282846 | ||
|
|
c3960a99be | ||
|
|
e73ffa7449 | ||
|
|
37bd490072 | ||
|
|
24c53e5ce7 | ||
|
|
aa7f886795 | ||
|
|
00dec8c5a0 | ||
|
|
578e60c0bc | ||
|
|
8b21851353 | ||
|
|
15006086d2 | ||
|
|
bc3b923945 | ||
|
|
76ba9e5f85 | ||
|
|
44eb5fced2 | ||
|
|
172d8267ef | ||
|
|
f162f1e3d4 | ||
|
|
6fa0029d0b | ||
|
|
028e78a0c9 | ||
|
|
440a4e919a | ||
|
|
b8f39198e1 | ||
|
|
966bbe3957 | ||
|
|
522bf6a019 | ||
|
|
1689315c3a | ||
|
|
d4f7ae522e | ||
|
|
f05f70a9e5 | ||
|
|
850ee73048 | ||
|
|
5842f3f914 | ||
|
|
f303478850 | ||
|
|
8bdadc7ba3 | ||
|
|
6b57118add | ||
|
|
ffb645671c | ||
|
|
e63ce25f5f | ||
|
|
d31b30ba4a | ||
|
|
31eff96614 | ||
|
|
2ea8054240 | ||
|
|
5a2e141ed6 | ||
|
|
fa343e0768 | ||
|
|
8969338b2a | ||
|
|
4c720d6855 | ||
|
|
ad175d20e3 | ||
|
|
fb2f0c9158 | ||
|
|
7daa46e73e | ||
|
|
195185dc52 | ||
|
|
8a9dce1a99 | ||
|
|
c690e8eb76 | ||
|
|
9d867cbff6 | ||
|
|
f22699fe20 | ||
|
|
a7db3883aa | ||
|
|
7fcefa53c9 | ||
|
|
bdb76c3d90 | ||
|
|
168b36d419 | ||
|
|
a4f57e7a5c | ||
|
|
6678501501 | ||
|
|
fa0045e01b | ||
|
|
1b816d3185 | ||
|
|
24949e183f |
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
# Bulwark Webmail — Development Configuration
|
# Bulwark Webmail - Development Configuration
|
||||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
# 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.
|
# No external mail server required - great for UI development and testing.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# cp .env.dev.example .env.local
|
# cp .env.dev.example .env.local
|
||||||
# npm run dev
|
# npm run dev
|
||||||
# Open http://localhost:3000 — log in with any username/password.
|
# Open http://localhost:3000 - log in with any username/password.
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Mock JMAP Server
|
# Mock JMAP Server
|
||||||
|
|||||||
+67
-24
@@ -1,4 +1,4 @@
|
|||||||
# Bulwark Webmail — Production Configuration
|
# Bulwark Webmail - Production Configuration
|
||||||
# Copy this file to .env.local and fill in your values.
|
# Copy this file to .env.local and fill in your values.
|
||||||
# For development with the built-in mock server, see .env.dev.example instead.
|
# For development with the built-in mock server, see .env.dev.example instead.
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
# JMAP Server (required)
|
# JMAP Server (required)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# App name displayed in the UI
|
# App name displayed in the UI, browser tab title, and PWA manifest.
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
@@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
||||||
# STALWART_FEATURES=true
|
# 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
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# OAuth / OpenID Connect (optional)
|
# OAuth / OpenID Connect (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -76,11 +71,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||||
# For Docker, the working directory is /app, so the default resolves to
|
# For Docker, the working directory is /app, so the default resolves to
|
||||||
# /app/data/settings — mount a persistent volume there:
|
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
|
||||||
# volumes:
|
|
||||||
# - bulwark-settings:/app/data/settings
|
|
||||||
# SETTINGS_DATA_DIR=./data/settings
|
# SETTINGS_DATA_DIR=./data/settings
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin Dashboard Data
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Directory for admin dashboard state: config overrides, admin password hash,
|
||||||
|
# installed plugins/themes, and audit logs (default: ./data/admin).
|
||||||
|
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
||||||
|
# volume there (see docker-compose.yml).
|
||||||
|
# ADMIN_DATA_DIR=./data/admin
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Server Listen Address
|
# Server Listen Address
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -106,41 +109,81 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Branding (all optional)
|
# Branding (all optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Custom favicon for the browser tab.
|
# ---------------------------------------------------------------------------
|
||||||
|
# App identity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Short name for the app, used in contexts where space is limited
|
||||||
|
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
|
||||||
|
# APP_SHORT_NAME=Bulwark
|
||||||
|
|
||||||
|
# Description shown in the PWA manifest (displayed by the OS during install).
|
||||||
|
# Defaults to a generic Bulwark description if not set.
|
||||||
|
# APP_DESCRIPTION=Your personal webmail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Icons & favicon
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Custom favicon shown in the browser tab.
|
||||||
# Supported formats: SVG (recommended), PNG, ICO.
|
# Supported formats: SVG (recommended), PNG, ICO.
|
||||||
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
# Can be an absolute URL or a path relative to the public/ directory.
|
|
||||||
# Defaults to the Bulwark favicon if not set.
|
# Defaults to the Bulwark favicon if not set.
|
||||||
# FAVICON_URL=/branding/my-favicon.svg
|
# FAVICON_URL=/branding/my-favicon.svg
|
||||||
|
|
||||||
# Custom logos for the sidebar (shown in the main app after login).
|
# Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
|
||||||
|
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
|
||||||
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
|
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
|
||||||
|
# PWA_ICON_URL=/branding/my-icon.svg
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PWA appearance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Color applied to the browser UI chrome when the app is installed as a PWA
|
||||||
|
# (address bar, status bar on Android). Default: #ffffff
|
||||||
|
# PWA_THEME_COLOR=#3b82f6
|
||||||
|
|
||||||
|
# Background color shown on the PWA splash screen while the app is loading.
|
||||||
|
# Should match your app's main background color. Default: #ffffff
|
||||||
|
# PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Logos shown in the sidebar (main app, after login).
|
||||||
# Supported formats: SVG (recommended), PNG, WebP.
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
# Recommended size: min 24×24px, max 128×128px
|
# Recommended size: min 24×24px, max 128×128px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# If not set, no logo is shown in the sidebar.
|
# If not set, no logo is shown in the sidebar.
|
||||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||||
|
|
||||||
# Custom logo images for the login page.
|
# Logos shown on the login page.
|
||||||
# Supported formats: SVG (recommended), PNG, WebP.
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
# Recommended size: min 32×32px, max 512×512px
|
# Recommended size: min 32×32px, max 512×512px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
|
# Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
|
||||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
#
|
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
|
||||||
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
|
|
||||||
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
|
|
||||||
# Company or organization name displayed above the version on the login page
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Company name shown above the version number on the login page.
|
||||||
LOGIN_COMPANY_NAME=Bulwark Webmail
|
LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL for the imprint/legal notice link on the login page
|
# URL for the imprint / legal notice link on the login page.
|
||||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
|
||||||
# URL for the privacy policy link on the login page
|
# URL for the privacy policy link on the login page.
|
||||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
|
||||||
# URL for the company website link on the login page
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+127
-2
@@ -1,14 +1,137 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.5.2 (2026-04-27)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
|
||||||
|
- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
||||||
|
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
||||||
|
- **i18n**: Czech language support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Validate URLs before outbound fetch
|
||||||
|
- **Calendar**: Prevent drag creation on touch events in the time grid
|
||||||
|
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
|
||||||
|
- **Mail**: Hide preview line in compact density to match settings preview (#223)
|
||||||
|
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
|
||||||
|
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
|
||||||
|
|
||||||
|
## 1.5.1 (2026-04-25)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
|
||||||
|
- **Mail**: Right-click context menu on the folders sidebar
|
||||||
|
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
|
||||||
|
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
|
||||||
|
- **Junk**: Setting to show avatars in the Junk folder (off by default)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
|
||||||
|
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
|
||||||
|
- **Folders**: Stop flicker on background folder refresh
|
||||||
|
- **Email**: Preserve search/filter on batch move and archive
|
||||||
|
- **Email**: Preserve search/filter when moving emails via drag-drop
|
||||||
|
- **i18n**: Improve Korean flag
|
||||||
|
|
||||||
|
## 1.5.0 (2026-04-22)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
|
||||||
|
- **Admin**: Add API Keys management and IP allowlist for App Passwords
|
||||||
|
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
|
||||||
|
- **Contacts**: Add contact activity component showing recent emails and upcoming events
|
||||||
|
- **Contacts**: Add right-click context menu
|
||||||
|
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
|
||||||
|
- **Calendar**: Support resizing events from the top edge
|
||||||
|
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
|
||||||
|
- **Calendar**: Optimize layout of overlapping events
|
||||||
|
- **Calendar**: Add collapsible details to calendar invitation banner
|
||||||
|
- **Email**: Implement batch archiving and bulk moving of emails
|
||||||
|
- **Email**: Show full folder path in move/drop toast
|
||||||
|
- **Settings**: Reorganize settings into 6 groups with clearer tabs
|
||||||
|
- **Navigation**: Add account-addition button to the navigation rail
|
||||||
|
- **Mobile**: Streamline email viewer header layout
|
||||||
|
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
|
||||||
|
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
|
||||||
|
- **Mailbox**: Improve error message on mailbox creation failure
|
||||||
|
- **Auth**: Skip `checkAuth` on route change when already authenticated
|
||||||
|
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
|
||||||
|
- **UI**: Align hover styles and selection-toggle target with focused item
|
||||||
|
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
|
||||||
|
|
||||||
|
### Chore
|
||||||
|
|
||||||
|
- **i18n**: Add missing translation keys
|
||||||
|
- **Deps**: Bump dependencies to latest compatible versions
|
||||||
|
|
||||||
|
## 1.4.14 (2026-04-16)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- [@pr0ton11](https://github.com/pr0ton11)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Email**: Add unified mailbox across accounts and sidebar icons toggle
|
||||||
|
- **Email**: Enhance email deletion and spam handling with improved parameterization
|
||||||
|
- **Sieve**: Enhance external rule handling in parser and store (#201)
|
||||||
|
- **Plugins**: Add i18n API, render hooks, and new intercept hooks to plugin system
|
||||||
|
- **PWA**: Dynamic PWA manifest with configurable name, description, and icons
|
||||||
|
- **PWA**: Show app name and logo in install prompt
|
||||||
|
- **i18n**: Add Ukrainian language with flags and missing translation keys
|
||||||
|
- **i18n**: Configurable locale prefix via `NEXT_PUBLIC_LOCALE_PREFIX`
|
||||||
|
- **API**: Add `apiFetch` helper for mount-prefix-aware API calls
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Send iMIP invitation emails when creating or updating calendar events (#192)
|
||||||
|
- **Calendar**: RFC 5545/6047 compliance for outgoing iMIP calendar emails
|
||||||
|
- **Calendar**: Add `calendarAddress` and `replyTo` to participants for Stalwart compatibility (#189, #192)
|
||||||
|
- **Calendar**: Improve CalDAV task detection for external clients like Thunderbird (#84)
|
||||||
|
- **Email**: Hide ICS attachments from attachment list when invitation banner is shown
|
||||||
|
- **Email**: Send before storing in Sent via `onSuccessUpdateEmail` (#188)
|
||||||
|
- **Email**: Standardize tag naming and fix unknown keyword display (#184, #185)
|
||||||
|
- **i18n**: Skip intl middleware for paths already containing a locale prefix
|
||||||
|
- **Docs**: Document PWA and branding env vars in `.env.example`
|
||||||
|
- **Docs**: Use `company` consistently in `.env.example` branding comments
|
||||||
|
|
||||||
## 1.4.13 (2026-04-12)
|
## 1.4.13 (2026-04-12)
|
||||||
|
|
||||||
Thank you for your donations:
|
Thank you for your donations:
|
||||||
|
|
||||||
**One-time**
|
**One-time**
|
||||||
|
|
||||||
- [@boris22100](https://github.com/boris22100)
|
- [@boris22100](https://github.com/boris22100)
|
||||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
**Monthly**
|
**Monthly**
|
||||||
|
|
||||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -38,9 +161,11 @@ Thank you for your donations:
|
|||||||
Thank you for your donations:
|
Thank you for your donations:
|
||||||
|
|
||||||
**One-time**
|
**One-time**
|
||||||
|
|
||||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
**Monthly**
|
**Monthly**
|
||||||
|
|
||||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -436,7 +561,7 @@ Thank you for your donations:
|
|||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
- **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
|
- **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)
|
## 1.1.1 (2026-02-28)
|
||||||
@@ -446,7 +571,7 @@ Thank you for your donations:
|
|||||||
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
||||||
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
||||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||||
|
|
||||||
## 1.1.0 (2026-02-28)
|
## 1.1.0 (2026-02-28)
|
||||||
|
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# Features
|
||||||
|
|
||||||
|
## Mail
|
||||||
|
|
||||||
|
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
|
||||||
|
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||||
|
- Unified mailbox view across all connected accounts
|
||||||
|
- Draft auto-save with identity preservation
|
||||||
|
- Attachment upload, download, and inline preview; forgotten-attachment warning
|
||||||
|
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||||
|
- Batch operations – multi-select, archive, delete, move, tag
|
||||||
|
- Archive modes – direct, by year, or by month
|
||||||
|
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||||
|
- Star/unstar with configurable mark-as-read delay
|
||||||
|
- Virtual scrolling for large mailboxes
|
||||||
|
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||||
|
- Plain-text composer mode and Reply-To support
|
||||||
|
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||||
|
- Folder management with icon picker, subfolders, and sidebar counts
|
||||||
|
- Print directly from the viewer
|
||||||
|
- Browser history sync for back/forward navigation
|
||||||
|
|
||||||
|
## Calendar
|
||||||
|
|
||||||
|
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
||||||
|
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
||||||
|
- Recurring events with scoped edit/delete (this / this and following / all)
|
||||||
|
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
||||||
|
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
||||||
|
- iCalendar import with preview, bulk create, and UID deduplication
|
||||||
|
- iCal / webcal subscriptions with editing and batch import
|
||||||
|
- Auto-generated birthday calendar from contacts
|
||||||
|
- Virtual locations (video conference URLs) as first-class event fields
|
||||||
|
- Task management with due dates, priority, and completion status
|
||||||
|
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||||
|
- Week numbers, event hover preview, notifications with sound picker
|
||||||
|
- Real-time sync via JMAP push
|
||||||
|
|
||||||
|
## Contacts
|
||||||
|
|
||||||
|
- JMAP sync (RFC 9553 / 9610) with local fallback
|
||||||
|
- Multiple address books with drag-and-drop between books
|
||||||
|
- Contact groups with member management
|
||||||
|
- vCard import/export (RFC 6350) with duplicate detection
|
||||||
|
- Trusted senders stored in a dedicated JMAP address book
|
||||||
|
- Autocomplete in the composer (To / Cc / Bcc)
|
||||||
|
|
||||||
|
## Filters & Templates
|
||||||
|
|
||||||
|
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||||
|
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||||
|
- Preserves rules authored in other clients
|
||||||
|
- Raw Sieve editor with syntax validation
|
||||||
|
- Vacation responder with date range scheduling
|
||||||
|
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||||
|
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||||
|
- Dynamic upload limits based on server configuration
|
||||||
|
- Grid and list views with sorting by name, size, or date
|
||||||
|
- Previews for images, text, audio, and video
|
||||||
|
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||||
|
|
||||||
|
## Security & Privacy
|
||||||
|
|
||||||
|
- External content blocked by default, with a trusted senders list
|
||||||
|
- HTML sanitization via DOMPurify
|
||||||
|
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||||
|
- SPF / DKIM / DMARC status indicators
|
||||||
|
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||||
|
- TOTP two-factor authentication
|
||||||
|
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||||
|
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
||||||
|
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
||||||
|
- Plugin hardening with dangerous-pattern detection and admin approval
|
||||||
|
- Newsletter unsubscribe (RFC 2369)
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
- Three-pane layout with resizable columns
|
||||||
|
- Dark and light themes with intelligent email color transformation
|
||||||
|
- Responsive desktop, tablet, and mobile layouts
|
||||||
|
- Full keyboard navigation
|
||||||
|
- Drag-and-drop email organization and tag assignment
|
||||||
|
- Interactive guided tour for new users
|
||||||
|
- Right-click context menus, toast notifications with undo
|
||||||
|
- Customizable toolbar position, favicon, and login branding
|
||||||
|
- Pinnable sidebar apps with drag-and-drop reordering
|
||||||
|
- Encrypted settings sync across devices
|
||||||
|
- Storage quota display
|
||||||
|
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||||
|
|
||||||
|
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||||
|
|
||||||
|
## Identity & Multi-Account
|
||||||
|
|
||||||
|
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
|
||||||
|
- Account switcher with connection status and default account selection
|
||||||
|
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||||
|
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
|
||||||
|
- Shared folders across accounts
|
||||||
|
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
|
## Admin & Extensibility
|
||||||
|
|
||||||
|
- Stalwart admin dashboard with dedicated policy sections
|
||||||
|
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
|
||||||
|
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||||
|
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||||
|
- Bundled plugins including Jitsi Meet calendar integration
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- Progressive Web App with service worker, install prompt, and dynamic manifest
|
||||||
|
- Automatic update check with server-side logging of new releases
|
||||||
|
- Structured logging (`text` or `json`) with category-based levels
|
||||||
|
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||||
|
- Demo mode with fixture data – no mail server required
|
||||||
@@ -8,12 +8,11 @@
|
|||||||
|
|
||||||
# Bulwark Webmail
|
# Bulwark Webmail
|
||||||
|
|
||||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
|
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||||
Built with Next.js and the JMAP protocol.
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -22,190 +21,56 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
|
||||||
|
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
|
||||||
|
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
|
||||||
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td><sub><b>Calendar</b> – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
|
||||||
|
<td><sub><b>Contacts</b> – multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
|
||||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
</tr>
|
||||||
|
<tr>
|
||||||
**Contacts** — Contact management with groups and vCard support
|
<td><img src="screenshots/theme.png" alt="Themes" /></td>
|
||||||
|
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
|
||||||
</td>
|
</tr>
|
||||||
<td width="50%">
|
<tr>
|
||||||
|
<td><sub><b>Themes</b> – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
|
||||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
<td><sub><b>Plugins</b> – extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
|
||||||
|
</tr>
|
||||||
**Files** — Cloud file browser with upload, preview, and folder navigation
|
<tr>
|
||||||
|
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
|
||||||
</td>
|
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
||||||
|
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<details>
|
## Overview
|
||||||
<summary>More screenshots</summary>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td width="50%">
|
|
||||||
|
|
||||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||||
|
|
||||||
**Light mode** — Full theme support with intelligent color transformation
|
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||||
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|
||||||
</td>
|
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
||||||
<td width="50%">
|
|
||||||
|
|
||||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
### Mail
|
|
||||||
|
|
||||||
- **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
|
|
||||||
- **Answered/forwarded status icons** in email list and thread views
|
|
||||||
- **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
|
|
||||||
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
|
||||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
|
||||||
- **Tag counts** — unread and total counts displayed in sidebar
|
|
||||||
|
|
||||||
### Calendar
|
|
||||||
|
|
||||||
- **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
|
|
||||||
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
|
|
||||||
- **Week numbers** in mini-calendar sidebar
|
|
||||||
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
|
||||||
- **Real-time sync** via JMAP push
|
|
||||||
|
|
||||||
### Contacts
|
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
### Filters & Automation
|
|
||||||
|
|
||||||
- **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.)
|
|
||||||
|
|
||||||
### Files
|
|
||||||
|
|
||||||
- **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 & Privacy
|
|
||||||
|
|
||||||
- **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 and non-interactive SSO for embedded/iframe deployments
|
|
||||||
- **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
|
|
||||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
|
||||||
- **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
|
|
||||||
- **Interactive guided tour** — onboarding walkthrough for new users
|
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
|
||||||
- **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 · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
|
|
||||||
|
|
||||||
Automatic browser detection with persistent preference.
|
|
||||||
|
|
||||||
### Identity Management
|
|
||||||
|
|
||||||
- **Multiple sender identities** with per-identity signatures
|
|
||||||
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
|
|
||||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
|
||||||
- **Identity badges** in viewer and email list
|
|
||||||
|
|
||||||
### Operations
|
|
||||||
|
|
||||||
- **Automatic update check** — server logs when a newer release is available
|
|
||||||
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Docker (recommended)
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 \
|
docker run -d -p 3000:3000 \
|
||||||
@@ -217,7 +82,7 @@ Or with Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local – set JMAP_SERVER_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -228,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git
|
|||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local – set JMAP_SERVER_URL
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start dev server (mock JMAP server included)
|
npm run dev # Dev server with a mock JMAP server
|
||||||
npm run typecheck # Type checking
|
npm run typecheck
|
||||||
npm run lint # Linting
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Edit `.env.local`:
|
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Required
|
# Required
|
||||||
@@ -252,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com
|
|||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Server Listen Address</summary>
|
<summary>Server listen address</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||||
PORT=3000 # Default listen port
|
PORT=3000
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>OAuth2/OIDC (SSO)</summary>
|
<summary>OAuth2 / OIDC</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||||
@@ -280,14 +143,77 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Remember Me</summary>
|
<summary>Session & settings sync</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
SESSION_SECRET= # openssl rand -base64 32
|
||||||
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
|
||||||
|
|
||||||
|
SETTINGS_SYNC_ENABLED=true
|
||||||
|
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
|
||||||
```
|
```
|
||||||
|
|
||||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Custom JMAP endpoint</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Branding & PWA</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME=My Webmail
|
||||||
|
APP_SHORT_NAME=Webmail
|
||||||
|
APP_DESCRIPTION=Your personal mail
|
||||||
|
|
||||||
|
FAVICON_URL=/branding/favicon.svg
|
||||||
|
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
|
||||||
|
PWA_THEME_COLOR=#3b82f6
|
||||||
|
PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||||
|
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||||
|
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
|
||||||
|
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
|
||||||
|
|
||||||
|
LOGIN_COMPANY_NAME=My Company
|
||||||
|
LOGIN_WEBSITE_URL=https://example.com
|
||||||
|
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Extension directory</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables the admin marketplace for browsing and installing plugins and themes.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Stalwart integration & logging</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
STALWART_FEATURES=true # password change, Sieve filters, etc.
|
||||||
|
|
||||||
|
LOG_FORMAT=text # "text" or "json"
|
||||||
|
LOG_LEVEL=info # error | warn | info | debug
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -321,19 +247,16 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
|||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
[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.
|
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[GNU AGPL v3](LICENSE)
|
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
|
||||||
|
|
||||||
This repository also preserves the original MIT attribution notice for the
|
|
||||||
fork lineage in [NOTICE](NOTICE).
|
|
||||||
|
|
||||||
## Acknowledgments
|
## 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.
|
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
|
|||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||||
if (!state || state !== savedState) {
|
if (!state || state !== savedState) {
|
||||||
setError("invalid_state");
|
setError("invalid_state");
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
|
|||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
});
|
});
|
||||||
} else if (state) {
|
} else if (state) {
|
||||||
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
loginWithServerSso(code, state)
|
loginWithServerSso(code, state)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
|||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
|
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
|
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||||
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
||||||
@@ -43,7 +47,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
@@ -68,7 +76,8 @@ export default function CalendarPage() {
|
|||||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||||
|
removeCalendar, clearCalendarEvents,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
@@ -87,6 +96,11 @@ export default function CalendarPage() {
|
|||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
|
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||||
|
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||||
|
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
|
||||||
|
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
@@ -112,8 +126,25 @@ export default function CalendarPage() {
|
|||||||
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
||||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||||
|
|
||||||
// Check auth on mount
|
// Keep detailEvent in sync with store events (e.g. after update + refetch)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (detailEvent) {
|
||||||
|
const updated = events.find(e => e.id === detailEvent.id);
|
||||||
|
if (updated && updated !== detailEvent) {
|
||||||
|
setDetailEvent(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [events, detailEvent]);
|
||||||
|
|
||||||
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -151,9 +182,14 @@ export default function CalendarPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [client, refreshAllSubscriptions]);
|
}, [client, refreshAllSubscriptions]);
|
||||||
|
|
||||||
// Auto-add birthday calendar to selected IDs when enabled
|
// Auto-add birthday calendar to selected IDs only when the setting flips
|
||||||
|
// off→on. Firing on every mount would undo a user's manual hide via the
|
||||||
|
// sidebar each time they navigate back to the calendar (see #204).
|
||||||
|
const prevShowBirthdayRef = useRef(showBirthdayCalendar);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
const wasShown = prevShowBirthdayRef.current;
|
||||||
|
prevShowBirthdayRef.current = showBirthdayCalendar;
|
||||||
|
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||||
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
||||||
}
|
}
|
||||||
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -347,6 +383,18 @@ export default function CalendarPage() {
|
|||||||
openEditModal(event);
|
openEditModal(event);
|
||||||
}, [closeDetail, openEditModal]);
|
}, [closeDetail, openEditModal]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
contextMenu: eventContextMenu,
|
||||||
|
openContextMenu: openEventContextMenu,
|
||||||
|
closeContextMenu: closeEventContextMenu,
|
||||||
|
menuRef: eventContextMenuRef,
|
||||||
|
} = useContextMenu<CalendarEvent>();
|
||||||
|
|
||||||
|
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||||
|
closeDetail();
|
||||||
|
openEventContextMenu(e, event);
|
||||||
|
}, [closeDetail, openEventContextMenu]);
|
||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
if (calendarHoverPreview === 'off') return;
|
if (calendarHoverPreview === 'off') return;
|
||||||
@@ -407,6 +455,20 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
}, [client, fetchEvents]);
|
||||||
|
|
||||||
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
|
// and refresh calendar data via JMAP instead of reloading the page.
|
||||||
|
useRefreshGesture({
|
||||||
|
enabled: isAuthenticated && !!client,
|
||||||
|
onRefresh: async () => {
|
||||||
|
if (!client) return;
|
||||||
|
await Promise.all([
|
||||||
|
fetchCalendars(client),
|
||||||
|
refetchCurrentRange(),
|
||||||
|
refreshAllSubscriptions(client),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||||
if (!event.start) {
|
if (!event.start) {
|
||||||
return;
|
return;
|
||||||
@@ -722,6 +784,73 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [detailEvent, client, updateEvent, t]);
|
}, [detailEvent, client, updateEvent, t]);
|
||||||
|
|
||||||
|
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
|
||||||
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
|
const start = parseISO(event.start);
|
||||||
|
const newStart = addDays(start, 1);
|
||||||
|
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
|
duration: event.duration,
|
||||||
|
timeZone: event.timeZone,
|
||||||
|
showWithoutTime: event.showWithoutTime,
|
||||||
|
calendarIds: { ...event.calendarIds },
|
||||||
|
status: "confirmed",
|
||||||
|
freeBusyStatus: event.freeBusyStatus,
|
||||||
|
privacy: event.privacy,
|
||||||
|
});
|
||||||
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
|
try {
|
||||||
|
const created = await createEvent(client, data);
|
||||||
|
if (created) {
|
||||||
|
toast.success(t("notifications.event_duplicated"));
|
||||||
|
openEditModal(created);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [client, createEvent, openEditModal, t]);
|
||||||
|
|
||||||
|
const handleExportICS = useCallback((event: CalendarEvent) => {
|
||||||
|
try {
|
||||||
|
downloadEventICS(event);
|
||||||
|
toast.success(t("notifications.event_exported"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(event.title || "");
|
||||||
|
toast.success(t("notifications.title_copied"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
|
||||||
|
const uri = event.virtualLocations
|
||||||
|
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
|
||||||
|
: undefined;
|
||||||
|
if (!uri) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(uri);
|
||||||
|
toast.success(t("notifications.link_copied"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
|
||||||
|
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
|
||||||
|
handleDeleteEvent(event.id, hasParticipants || undefined);
|
||||||
|
}, [handleDeleteEvent]);
|
||||||
|
|
||||||
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!detailEvent || !client) return;
|
if (!detailEvent || !client) return;
|
||||||
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
||||||
@@ -831,6 +960,7 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -847,6 +977,7 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
@@ -865,6 +996,7 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -882,6 +1014,7 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -979,6 +1112,42 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||||
|
onCreateEvent={(cal: Calendar) => {
|
||||||
|
setDefaultCalendarIdForCreate(cal.id);
|
||||||
|
openCreateModal();
|
||||||
|
}}
|
||||||
|
onClearCalendar={client ? async (cal: Calendar) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: tMgmt("clear_events"),
|
||||||
|
message: tMgmt("confirm_clear", { name: cal.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: tMgmt("clear_events"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const count = await clearCalendarEvents(client, cal.id);
|
||||||
|
toast.success(tMgmt("events_cleared", { count }));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt("error_clear"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onDeleteCalendar={client ? async (cal: Calendar) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: tMgmt("delete"),
|
||||||
|
message: tMgmt("confirm_delete", { name: cal.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: tMgmt("delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await removeCalendar(client, cal.id);
|
||||||
|
toast.success(tMgmt("calendar_deleted"));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt("error_delete"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
@@ -1042,11 +1211,12 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||||
onPreviewChange={setPendingPreview}
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
@@ -1096,6 +1266,22 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{eventContextMenu.data && (
|
||||||
|
<EventContextMenu
|
||||||
|
event={eventContextMenu.data}
|
||||||
|
position={eventContextMenu.position}
|
||||||
|
isOpen={eventContextMenu.isOpen}
|
||||||
|
onClose={closeEventContextMenu}
|
||||||
|
menuRef={eventContextMenuRef}
|
||||||
|
onEdit={() => openEditModal(eventContextMenu.data!)}
|
||||||
|
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
|
||||||
|
onExportICS={() => handleExportICS(eventContextMenu.data!)}
|
||||||
|
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
|
||||||
|
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
|
||||||
|
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
<EventDetailPopover
|
<EventDetailPopover
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
@@ -1122,11 +1308,12 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={true}
|
isMobile={true}
|
||||||
/>
|
/>
|
||||||
@@ -1166,6 +1353,33 @@ export default function CalendarPage() {
|
|||||||
onSelect={handleScopeSelect}
|
onSelect={handleScopeSelect}
|
||||||
onClose={() => setPendingScopeAction(null)}
|
onClose={() => setPendingScopeAction(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
|
||||||
|
{showCreateCalendar && client && (
|
||||||
|
<CreateCalendarModal
|
||||||
|
client={client}
|
||||||
|
onClose={() => setShowCreateCalendar(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sharingCalendarId && client && (() => {
|
||||||
|
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
|
||||||
|
if (!cal) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="calendar"
|
||||||
|
collectionName={cal.name}
|
||||||
|
shareWith={cal.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingCalendarId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-28
@@ -26,7 +26,9 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
|||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
|
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -74,6 +76,8 @@ export default function ContactsPage() {
|
|||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
renameAddressBook,
|
renameAddressBook,
|
||||||
|
removeAddressBook,
|
||||||
|
shareAddressBook,
|
||||||
renameKeyword,
|
renameKeyword,
|
||||||
importContacts,
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
@@ -82,6 +86,8 @@ export default function ContactsPage() {
|
|||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||||
|
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||||
|
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
@@ -97,13 +103,20 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Panel resize state - contact list
|
// Panel resize state - contact list
|
||||||
const [listWidth, setListWidth] = useState(() => {
|
const [listWidth, setListWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
|
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
|
||||||
});
|
});
|
||||||
const [isListResizing, setIsListResizing] = useState(false);
|
const [isListResizing, setIsListResizing] = useState(false);
|
||||||
const listDragStartWidth = useRef(320);
|
const listDragStartWidth = useRef(384);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -123,6 +136,16 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, supportsSync, fetchContacts]);
|
}, [client, supportsSync, fetchContacts]);
|
||||||
|
|
||||||
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
|
// and refresh contacts via JMAP instead of reloading the page.
|
||||||
|
useRefreshGesture({
|
||||||
|
enabled: isAuthenticated && !!client && supportsSync,
|
||||||
|
onRefresh: async () => {
|
||||||
|
if (!client) return;
|
||||||
|
await fetchContacts(client);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||||
@@ -161,21 +184,6 @@ export default function ContactsPage() {
|
|||||||
return getGroupMembers(activeCategory.groupId);
|
return getGroupMembers(activeCategory.groupId);
|
||||||
}, [activeCategory, individuals, getGroupMembers]);
|
}, [activeCategory, individuals, getGroupMembers]);
|
||||||
|
|
||||||
// Label for the current category
|
|
||||||
const categoryLabel = useMemo(() => {
|
|
||||||
if (activeCategory === "all") return t("tabs.all");
|
|
||||||
if (activeCategory === "uncategorized") return t("no_category");
|
|
||||||
if ("addressBookId" in activeCategory) {
|
|
||||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
|
||||||
return book?.name || t("tabs.all");
|
|
||||||
}
|
|
||||||
if ("keyword" in activeCategory) {
|
|
||||||
return activeCategory.keyword;
|
|
||||||
}
|
|
||||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
|
||||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
|
||||||
}, [activeCategory, contacts, addressBooks, t]);
|
|
||||||
|
|
||||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||||
setActiveCategory(category);
|
setActiveCategory(category);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -248,9 +256,7 @@ export default function ContactsPage() {
|
|||||||
setView("edit");
|
setView("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const deleteContactById = useCallback(async (contactId: string) => {
|
||||||
if (!selectedContact) return;
|
|
||||||
|
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("delete_confirm_title"),
|
title: t("delete_confirm_title"),
|
||||||
message: t("delete_confirm"),
|
message: t("delete_confirm"),
|
||||||
@@ -261,18 +267,60 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await deleteContact(client, selectedContact.id);
|
await deleteContact(client, contactId);
|
||||||
} else {
|
} else {
|
||||||
deleteLocalContact(selectedContact.id);
|
deleteLocalContact(contactId);
|
||||||
}
|
}
|
||||||
toast.success(t("toast.deleted"));
|
toast.success(t("toast.deleted"));
|
||||||
setView("list");
|
if (selectedContactId === contactId) setView("list");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete contact:', error);
|
console.error('Failed to delete contact:', error);
|
||||||
toast.error(t("toast.error_delete"));
|
toast.error(t("toast.error_delete"));
|
||||||
}
|
}
|
||||||
|
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedContact) return;
|
||||||
|
await deleteContactById(selectedContact.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditContact = useCallback((id: string) => {
|
||||||
|
setSelectedContact(id);
|
||||||
|
setView("edit");
|
||||||
|
}, [setSelectedContact]);
|
||||||
|
|
||||||
|
const handleDeleteContact = useCallback((contact: ContactCard) => {
|
||||||
|
void deleteContactById(contact.id);
|
||||||
|
}, [deleteContactById]);
|
||||||
|
|
||||||
|
const handleAddContactToGroup = useCallback((id: string) => {
|
||||||
|
clearSelection();
|
||||||
|
toggleContactSelection(id);
|
||||||
|
if (groups.length === 0) {
|
||||||
|
setView("group-create");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setView("bulk-add-to-group");
|
||||||
|
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||||
|
|
||||||
|
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||||
|
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
||||||
|
void _id; void _created; void _updated;
|
||||||
|
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||||
|
if (supportsSync && client) {
|
||||||
|
await createContact(client, data);
|
||||||
|
toast.success(t("toast.created"));
|
||||||
|
} else {
|
||||||
|
const localContact: ContactCard = {
|
||||||
|
id: `local-${generateUUID()}`,
|
||||||
|
addressBookIds: data.addressBookIds || {},
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
addLocalContact(localContact);
|
||||||
|
toast.success(t("toast.created"));
|
||||||
|
}
|
||||||
|
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||||
|
|
||||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await createContact(client, data);
|
await createContact(client, data);
|
||||||
@@ -286,6 +334,7 @@ export default function ContactsPage() {
|
|||||||
addLocalContact(localContact);
|
addLocalContact(localContact);
|
||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
}
|
}
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
setView("list");
|
setView("list");
|
||||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||||
|
|
||||||
@@ -303,6 +352,7 @@ export default function ContactsPage() {
|
|||||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
if (view === "group-create" || view === "group-edit") {
|
if (view === "group-create" || view === "group-edit") {
|
||||||
setView(selectedGroup ? "group-detail" : "list");
|
setView(selectedGroup ? "group-detail" : "list");
|
||||||
} else if (view === "bulk-add-to-group") {
|
} else if (view === "bulk-add-to-group") {
|
||||||
@@ -474,7 +524,7 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
@@ -574,6 +624,16 @@ export default function ContactsPage() {
|
|||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onAddToGroup={
|
||||||
|
selectedContact
|
||||||
|
? () => handleAddContactToGroup(selectedContact.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDuplicate={
|
||||||
|
selectedContact
|
||||||
|
? () => void handleDuplicateContact(selectedContact)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -637,6 +697,26 @@ export default function ContactsPage() {
|
|||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
|
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||||
|
onCreateContactInBook={(book) => {
|
||||||
|
setDefaultBookIdForCreate(book.id);
|
||||||
|
handleCreateNew();
|
||||||
|
}}
|
||||||
|
onDeleteAddressBook={client ? async (book) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("address_books.delete"),
|
||||||
|
message: t("address_books.confirm_delete", { name: book.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: t("address_books.delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await removeAddressBook(client, book);
|
||||||
|
toast.success(t("address_books.deleted"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("address_books.delete_failed"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -669,7 +749,6 @@ export default function ContactsPage() {
|
|||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
onSelectContact={handleSelectContact}
|
onSelectContact={handleSelectContact}
|
||||||
onCreateNew={handleCreateNew}
|
onCreateNew={handleCreateNew}
|
||||||
categoryLabel={categoryLabel}
|
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
selectedContactIds={selectedContactIds}
|
selectedContactIds={selectedContactIds}
|
||||||
onToggleSelection={toggleContactSelection}
|
onToggleSelection={toggleContactSelection}
|
||||||
@@ -679,6 +758,9 @@ export default function ContactsPage() {
|
|||||||
onBulkDelete={handleBulkDelete}
|
onBulkDelete={handleBulkDelete}
|
||||||
onBulkAddToGroup={handleBulkAddToGroup}
|
onBulkAddToGroup={handleBulkAddToGroup}
|
||||||
onBulkExport={handleBulkExport}
|
onBulkExport={handleBulkExport}
|
||||||
|
onEditContact={handleEditContact}
|
||||||
|
onDeleteContact={handleDeleteContact}
|
||||||
|
onAddContactToGroup={handleAddContactToGroup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -690,7 +772,7 @@ export default function ContactsPage() {
|
|||||||
setIsListResizing(false);
|
setIsListResizing(false);
|
||||||
localStorage.setItem("contacts-list-width", String(listWidth));
|
localStorage.setItem("contacts-list-width", String(listWidth));
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
|
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -783,6 +865,23 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sharingAddressBookId && client && (() => {
|
||||||
|
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||||
|
if (!book) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="addressBook"
|
||||||
|
collectionName={book.name}
|
||||||
|
shareWith={book.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingAddressBookId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
@@ -104,8 +105,15 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -127,6 +135,15 @@ export default function FilesPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, client, initClient]);
|
}, [isAuthenticated, client, initClient]);
|
||||||
|
|
||||||
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
|
// and refresh files via JMAP instead of reloading the page.
|
||||||
|
useRefreshGesture({
|
||||||
|
enabled: isAuthenticated && !!client && supportsFiles === true,
|
||||||
|
onRefresh: async () => {
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Check support and load root after client is initialized
|
// Check support and load root after client is initialized
|
||||||
const storeClient = useFileStore(s => s.client);
|
const storeClient = useFileStore(s => s.client);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
@@ -234,7 +234,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
const res = await fetch('/api/auth/sso/start', {
|
const res = await apiFetch('/api/auth/sso/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -255,7 +255,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
window.top!.location.href = authorize_url;
|
window.top!.location.href = authorize_url;
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restriction — fall back to current frame
|
// Cross-origin restriction - fall back to current frame
|
||||||
window.location.href = authorize_url;
|
window.location.href = authorize_url;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -759,7 +759,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Dev mode — logging in as dev@localhost
|
Dev mode - logging in as dev@localhost
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : oauthOnly ? (
|
) : oauthOnly ? (
|
||||||
|
|||||||
+414
-61
@@ -8,10 +8,13 @@ import { EmailViewer } from "@/components/email/email-viewer";
|
|||||||
import { EmailComposer } from "@/components/email/email-composer";
|
import { EmailComposer } from "@/components/email/email-composer";
|
||||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
@@ -19,7 +22,9 @@ import { useIdentityStore } from "@/stores/identity-store";
|
|||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
@@ -32,6 +37,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
@@ -64,6 +70,7 @@ export default function Home() {
|
|||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
@@ -155,8 +162,59 @@ export default function Home() {
|
|||||||
hasMoreEmails,
|
hasMoreEmails,
|
||||||
fetchTagCounts,
|
fetchTagCounts,
|
||||||
fetchEmailContent,
|
fetchEmailContent,
|
||||||
|
isUnifiedView,
|
||||||
|
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||||
|
refreshUnifiedCounts,
|
||||||
|
exitUnifiedView,
|
||||||
|
emptyMailbox,
|
||||||
|
markMailboxAsRead,
|
||||||
|
createMailbox,
|
||||||
|
renameMailbox,
|
||||||
|
deleteMailbox,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const connectedAccountsSignature = useMemo(
|
||||||
|
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
|
||||||
|
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||||
|
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||||
|
const result: UnifiedAccountClient[] = [];
|
||||||
|
for (const account of connected) {
|
||||||
|
const accountClient = clients.get(account.id);
|
||||||
|
if (!accountClient) continue;
|
||||||
|
result.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountLabel: account.label || account.email,
|
||||||
|
client: accountClient,
|
||||||
|
mailboxes: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const populateUnifiedAccountMailboxes = useCallback(
|
||||||
|
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
|
||||||
|
const populated = await Promise.all(
|
||||||
|
list.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const mailboxes = await entry.client.getMailboxes();
|
||||||
|
return { ...entry, mailboxes };
|
||||||
|
} catch (err) {
|
||||||
|
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return populated;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Browser back / forward integration. The restore handler reads the
|
// Browser back / forward integration. The restore handler reads the
|
||||||
// latest values from a ref so we don't have to recreate the callback on
|
// latest values from a ref so we don't have to recreate the callback on
|
||||||
// every render (and so the popstate listener is never stale).
|
// every render (and so the popstate listener is never stale).
|
||||||
@@ -208,7 +266,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Restore conversation thread (mobile only). We can clear it directly,
|
// Restore conversation thread (mobile only). We can clear it directly,
|
||||||
// but reopening requires the thread group; if the user pressed forward
|
// but reopening requires the thread group; if the user pressed forward
|
||||||
// to return to a thread, we silently skip — back navigation always works.
|
// to return to a thread, we silently skip - back navigation always works.
|
||||||
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||||
if (state.threadId === null) {
|
if (state.threadId === null) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
@@ -360,6 +418,20 @@ export default function Home() {
|
|||||||
handlers: keyboardHandlers,
|
handlers: keyboardHandlers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
|
// and refresh mail data via JMAP instead of reloading the page.
|
||||||
|
useRefreshGesture({
|
||||||
|
enabled: isAuthenticated && !!client,
|
||||||
|
onRefresh: async () => {
|
||||||
|
if (!client) return;
|
||||||
|
const state = useEmailStore.getState();
|
||||||
|
await Promise.all([
|
||||||
|
state.fetchMailboxes(client),
|
||||||
|
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Update page title based on context
|
// Update page title based on context
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let title = appName;
|
let title = appName;
|
||||||
@@ -392,8 +464,15 @@ export default function Home() {
|
|||||||
document.title = title;
|
document.title = title;
|
||||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -430,7 +509,10 @@ export default function Home() {
|
|||||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||||
const loadData = async () => {
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadData = async (attempt = 1) => {
|
||||||
try {
|
try {
|
||||||
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -442,6 +524,15 @@ export default function Home() {
|
|||||||
const state = useEmailStore.getState();
|
const state = useEmailStore.getState();
|
||||||
const selectedMailboxId = state.selectedMailbox;
|
const selectedMailboxId = state.selectedMailbox;
|
||||||
|
|
||||||
|
// On first login the server may still be provisioning mailboxes.
|
||||||
|
// Retry a few times with back-off before giving up.
|
||||||
|
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||||
|
const delay = Math.min(1000 * attempt, 5000);
|
||||||
|
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||||
|
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch emails for the selected mailbox
|
// Fetch emails for the selected mailbox
|
||||||
if (selectedMailboxId) {
|
if (selectedMailboxId) {
|
||||||
await fetchEmails(client, selectedMailboxId);
|
await fetchEmails(client, selectedMailboxId);
|
||||||
@@ -475,6 +566,12 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
client.closePushNotifications();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup push notifications on unmount
|
// Cleanup push notifications on unmount
|
||||||
@@ -485,13 +582,30 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
||||||
|
|
||||||
|
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||||
|
// than one account is connected. Runs whenever the set of connected accounts
|
||||||
|
// or the primary account's mailboxes change (a proxy for "something worth
|
||||||
|
// recounting happened").
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
if (built.length < 2) return;
|
||||||
|
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||||
|
refreshUnifiedCounts(populated);
|
||||||
|
});
|
||||||
|
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||||
|
|
||||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEmail || !client) return;
|
if (!selectedEmail || !client) return;
|
||||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
||||||
if (!selectedEmail.bodyValues) {
|
if (!selectedEmail.bodyValues) {
|
||||||
|
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||||
|
: undefined;
|
||||||
|
const fetchClient = perAccountClient ?? client;
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
fetchEmailContent(client, selectedEmail.id).finally(() => {
|
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
|
||||||
setLoadingEmail(false);
|
setLoadingEmail(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -579,7 +693,7 @@ export default function Home() {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
}) => {
|
}) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
@@ -693,8 +807,8 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToDelete) return;
|
||||||
|
|
||||||
// Check if we're currently in the trash or junk folder
|
// Check if we're currently in the trash or junk folder
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
@@ -713,7 +827,7 @@ export default function Home() {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteEmail(client, selectedEmail.id, true);
|
await deleteEmail(client, emailToDelete.id, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to permanently delete email:", error);
|
console.error("Failed to permanently delete email:", error);
|
||||||
}
|
}
|
||||||
@@ -722,7 +836,7 @@ export default function Home() {
|
|||||||
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
||||||
if (trashMailbox) {
|
if (trashMailbox) {
|
||||||
try {
|
try {
|
||||||
await moveToMailbox(client, selectedEmail.id, trashMailbox.id);
|
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to move email to trash:", error);
|
console.error("Failed to move email to trash:", error);
|
||||||
}
|
}
|
||||||
@@ -733,8 +847,11 @@ export default function Home() {
|
|||||||
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
||||||
if (!client || !emailToArchive) return;
|
if (!client || !emailToArchive) return;
|
||||||
|
|
||||||
// Find archive mailbox
|
// Read fresh mailboxes from the store – batch archive calls this in a loop,
|
||||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
// and each iteration needs to see folders created by prior iterations.
|
||||||
|
const currentMailboxes = useEmailStore.getState().mailboxes;
|
||||||
|
|
||||||
|
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||||
if (!archiveMailbox) return;
|
if (!archiveMailbox) return;
|
||||||
|
|
||||||
const { archiveMode } = useSettingsStore.getState();
|
const { archiveMode } = useSettingsStore.getState();
|
||||||
@@ -743,14 +860,12 @@ export default function Home() {
|
|||||||
if (archiveMode === 'single') {
|
if (archiveMode === 'single') {
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// Determine year/month from the email's received date
|
|
||||||
const emailDate = new Date(emailToArchive.receivedAt);
|
const emailDate = new Date(emailToArchive.receivedAt);
|
||||||
const year = emailDate.getFullYear().toString();
|
const year = emailDate.getFullYear().toString();
|
||||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||||
|
|
||||||
// Find or create year subfolder under archive
|
let yearMailbox = currentMailboxes.find(
|
||||||
let yearMailbox = mailboxes.find(
|
|
||||||
m => m.name === year && m.parentId === archiveId
|
m => m.name === year && m.parentId === archiveId
|
||||||
);
|
);
|
||||||
if (!yearMailbox) {
|
if (!yearMailbox) {
|
||||||
@@ -761,9 +876,9 @@ export default function Home() {
|
|||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// archiveMode === 'month' — find or create month subfolder under year
|
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
let monthMailbox = mailboxes.find(
|
const afterYear = useEmailStore.getState().mailboxes;
|
||||||
|
let monthMailbox = afterYear.find(
|
||||||
m => m.name === month && m.parentId === yearId
|
m => m.name === month && m.parentId === yearId
|
||||||
);
|
);
|
||||||
if (!monthMailbox) {
|
if (!monthMailbox) {
|
||||||
@@ -795,10 +910,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAsSpam = async () => {
|
const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToMark) return;
|
||||||
|
|
||||||
const emailId = selectedEmail.id;
|
const emailId = emailToMark.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await markAsSpam(client, emailId);
|
await markAsSpam(client, emailId);
|
||||||
@@ -826,11 +941,11 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUndoSpam = async () => {
|
const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToRestore) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await undoSpam(client, selectedEmail.id);
|
await undoSpam(client, emailToRestore.id);
|
||||||
|
|
||||||
const toastInstance = (await import('sonner')).toast;
|
const toastInstance = (await import('sonner')).toast;
|
||||||
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
||||||
@@ -886,6 +1001,32 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMailboxSelect = async (mailboxId: string) => {
|
const handleMailboxSelect = async (mailboxId: string) => {
|
||||||
|
if (isUnifiedMailboxId(mailboxId)) {
|
||||||
|
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||||
|
if (!role) return;
|
||||||
|
|
||||||
|
selectMailbox(mailboxId);
|
||||||
|
selectEmail(null);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
setActiveView("list");
|
||||||
|
}
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
const populated = await populateUnifiedAccountMailboxes(built);
|
||||||
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
refreshUnifiedCounts(populated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedView) {
|
||||||
|
exitUnifiedView();
|
||||||
|
}
|
||||||
|
|
||||||
selectMailbox(mailboxId);
|
selectMailbox(mailboxId);
|
||||||
selectEmail(null); // Clear selected email when switching mailboxes
|
selectEmail(null); // Clear selected email when switching mailboxes
|
||||||
|
|
||||||
@@ -965,10 +1106,202 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tCtxMenu = t;
|
||||||
|
|
||||||
|
const handleMarkFolderRead = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
const count = await markMailboxAsRead(client, mailboxId);
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId);
|
||||||
|
if (count > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkFolderTreeRead = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const collectIds = (rootId: string): string[] => {
|
||||||
|
const ids: string[] = [rootId];
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()!;
|
||||||
|
for (const mb of mailboxes) {
|
||||||
|
if (mb.parentId === current) {
|
||||||
|
ids.push(mb.id);
|
||||||
|
stack.push(mb.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = collectIds(mailboxId);
|
||||||
|
let total = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
total += await markMailboxAsRead(client, id);
|
||||||
|
}
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox);
|
||||||
|
if (total > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllFoldersRead = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'),
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const total = await client.markAllAsRead();
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||||
|
if (total > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmptyFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('email_list.empty_folder.confirm_title'),
|
||||||
|
message: tCtxMenu('email_list.empty_folder.confirm_message'),
|
||||||
|
confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await emptyMailbox(client, mailboxId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_emptied'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.new_subfolder'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.create'),
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await createMailbox(client, name, parentId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFolderFromContextMenu = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.new_folder'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_new_folder'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.create'),
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await createMailbox(client, name);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.rename'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_rename'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
defaultValue: mailbox.name,
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'),
|
||||||
|
});
|
||||||
|
if (!name || name === mailbox.name) return;
|
||||||
|
try {
|
||||||
|
await renameMailbox(client, mailboxId, name);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.delete_confirm_title'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.delete_folder'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMailbox(client, mailboxId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted'));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
|
||||||
|
if (jmapType === 'mailboxHasChild') {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children'));
|
||||||
|
} else if (jmapType === 'mailboxHasEmail') {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email'));
|
||||||
|
} else {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshMailboxes = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = logout;
|
const handleLogout = logout;
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (isUnifiedView) return;
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
if (!isFilterEmpty(searchFilters)) {
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
@@ -987,6 +1320,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleAdvancedSearch = async () => {
|
const handleAdvancedSearch = async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (isUnifiedView) return;
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -996,9 +1330,9 @@ export default function Home() {
|
|||||||
clearTimeout(advancedSearchDebounceRef.current);
|
clearTimeout(advancedSearchDebounceRef.current);
|
||||||
}
|
}
|
||||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||||
if (client) advancedSearch(client);
|
if (client && !isUnifiedView) advancedSearch(client);
|
||||||
}, 300);
|
}, 300);
|
||||||
}, [client, advancedSearch]);
|
}, [client, advancedSearch, isUnifiedView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1127,13 +1461,29 @@ export default function Home() {
|
|||||||
|
|
||||||
// Fetch the full content
|
// Fetch the full content
|
||||||
try {
|
try {
|
||||||
// Find selected mailbox to determine accountId (for shared folders)
|
// In unified view each email carries its own accountId. Use that
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
// account's client so we fetch from the server that actually owns it.
|
||||||
// Only pass accountId for shared mailboxes
|
const listEmail = emails.find(e => e.id === email.id);
|
||||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||||
|
const perAccountClient = emailAccountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||||
|
: undefined;
|
||||||
|
const fetchClient = perAccountClient ?? client;
|
||||||
|
|
||||||
const fullEmail = await client.getEmail(email.id, accountId);
|
// For shared folders on the primary client, we still need to pass the
|
||||||
|
// shared account's id. In unified view we use the per-account client
|
||||||
|
// directly, so no explicit accountId is needed.
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
const accountId = perAccountClient
|
||||||
|
? undefined
|
||||||
|
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
|
||||||
|
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||||
if (fullEmail) {
|
if (fullEmail) {
|
||||||
|
if (emailAccountId) {
|
||||||
|
fullEmail.accountId = emailAccountId;
|
||||||
|
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||||
|
}
|
||||||
selectEmail(fullEmail);
|
selectEmail(fullEmail);
|
||||||
// Mark-as-read logic is now handled by useEffect
|
// Mark-as-read logic is now handled by useEffect
|
||||||
}
|
}
|
||||||
@@ -1145,15 +1495,11 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle back navigation from viewer on mobile.
|
// Handle back navigation from viewer on mobile.
|
||||||
// Delegate to the browser history stack so this button is equivalent to
|
// Reset to list state directly. We can't just call window.history.back()
|
||||||
// the OS back button / mouse back button — popstate then restores the
|
// because the nav hook pushes a new entry for every email the user opens,
|
||||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
// so history.back() would pop to the previous email rather than the list.
|
||||||
// from a state that pushed history, so back() always lands on an app entry.
|
// The OS / hardware back button is still wired through popstate → handleNavRestore.
|
||||||
const handleMobileBack = () => {
|
const handleMobileBack = () => {
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.history.back();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (conversationThread) {
|
if (conversationThread) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
@@ -1308,6 +1654,15 @@ export default function Home() {
|
|||||||
onMailboxSelect={handleMailboxSelect}
|
onMailboxSelect={handleMailboxSelect}
|
||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
onUnreadFilterClick={handleUnreadFilterClick}
|
onUnreadFilterClick={handleUnreadFilterClick}
|
||||||
|
onMarkFolderRead={handleMarkFolderRead}
|
||||||
|
onMarkFolderTreeRead={handleMarkFolderTreeRead}
|
||||||
|
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||||
|
onEmptyFolder={handleEmptyFolderFromContextMenu}
|
||||||
|
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
|
||||||
|
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||||
|
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||||
|
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||||
|
onRefreshMailboxes={handleRefreshMailboxes}
|
||||||
onCompose={() => {
|
onCompose={() => {
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1368,9 +1723,12 @@ export default function Home() {
|
|||||||
} else {
|
} else {
|
||||||
selectAllEmails();
|
selectAllEmails();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (emails.length > 0) {
|
||||||
// Enter selection mode by selecting the first email
|
const currentId = selectedEmail?.id;
|
||||||
if (emails.length > 0) toggleEmailSelection(emails[0].id);
|
const target = currentId && emails.some((e) => e.id === currentId)
|
||||||
|
? currentId
|
||||||
|
: emails[0].id;
|
||||||
|
toggleEmailSelection(target);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1397,6 +1755,8 @@ export default function Home() {
|
|||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
data-tour="search-input"
|
data-tour="search-input"
|
||||||
|
disabled={isUnifiedView}
|
||||||
|
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -1412,13 +1772,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
|
disabled={isUnifiedView}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
isUnifiedView && "opacity-50 cursor-not-allowed",
|
||||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
title={t("advanced_search.toggle_filters")}
|
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
|
||||||
>
|
>
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||||
@@ -1602,8 +1964,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={async (email) => {
|
onDelete={async (email) => {
|
||||||
selectEmail(email);
|
await handleDelete(email);
|
||||||
await handleDelete();
|
|
||||||
}}
|
}}
|
||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
await handleArchive(email);
|
await handleArchive(email);
|
||||||
@@ -1617,12 +1978,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMarkAsSpam={async (email) => {
|
onMarkAsSpam={async (email) => {
|
||||||
selectEmail(email);
|
await handleMarkAsSpam(email);
|
||||||
await handleMarkAsSpam();
|
|
||||||
}}
|
}}
|
||||||
onUndoSpam={async (email) => {
|
onUndoSpam={async (email) => {
|
||||||
selectEmail(email);
|
await handleUndoSpam(email);
|
||||||
await handleUndoSpam();
|
|
||||||
}}
|
}}
|
||||||
onEditDraft={(email) => {
|
onEditDraft={(email) => {
|
||||||
handleEditDraft(email);
|
handleEditDraft(email);
|
||||||
@@ -1694,7 +2053,8 @@ export default function Home() {
|
|||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
receivedAt: selectedEmail.receivedAt
|
receivedAt: selectedEmail.receivedAt,
|
||||||
|
attachments: selectedEmail.attachments,
|
||||||
} : undefined)}
|
} : undefined)}
|
||||||
initialDraftText={composerDraftText}
|
initialDraftText={composerDraftText}
|
||||||
initialData={pendingDraft}
|
initialData={pendingDraft}
|
||||||
@@ -1772,14 +2132,6 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Header for Viewer */}
|
|
||||||
{isMobile && activeView === "viewer" && (
|
|
||||||
<MobileViewerHeader
|
|
||||||
subject={selectedEmail?.subject}
|
|
||||||
onBack={handleMobileBack}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||||
<EmailViewer
|
<EmailViewer
|
||||||
email={selectedEmail}
|
email={selectedEmail}
|
||||||
@@ -1787,12 +2139,12 @@ export default function Home() {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onDelete={handleDelete}
|
onDelete={() => handleDelete()}
|
||||||
onArchive={() => handleArchive()}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetColorTag={handleSetColorTag}
|
||||||
onMarkAsSpam={handleMarkAsSpam}
|
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||||
onUndoSpam={handleUndoSpam}
|
onUndoSpam={() => handleUndoSpam()}
|
||||||
onMarkAsRead={async (emailId, read) => {
|
onMarkAsRead={async (emailId, read) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await markAsRead(client, emailId, read);
|
await markAsRead(client, emailId, read);
|
||||||
@@ -1862,6 +2214,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<PromptDialog {...promptDialogProps} />
|
||||||
<TotpReauthDialog />
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
|
|||||||
+152
-82
@@ -9,7 +9,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Palette,
|
Palette,
|
||||||
Mail,
|
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
UserPen,
|
UserPen,
|
||||||
@@ -20,17 +19,27 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Tags,
|
Tags,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Wrench,
|
|
||||||
BookUser,
|
BookUser,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Bell,
|
Bell,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
|
LayoutGrid,
|
||||||
|
BookOpen,
|
||||||
|
PenLine,
|
||||||
|
EyeOff,
|
||||||
|
Languages,
|
||||||
|
Info,
|
||||||
|
Bug,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||||
import { EmailSettings } from '@/components/settings/email-settings';
|
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||||
|
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||||
|
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||||
|
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||||
|
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||||
import { AccountSettings } from '@/components/settings/account-settings';
|
import { AccountSettings } from '@/components/settings/account-settings';
|
||||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||||
@@ -39,7 +48,8 @@ import { CalendarManagementSettings } from '@/components/settings/calendar-manag
|
|||||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
import { AboutDataSettings } from '@/components/settings/about-data-settings';
|
||||||
|
import { DebugSettings } from '@/components/settings/debug-settings';
|
||||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||||
@@ -62,8 +72,33 @@ import { useConfig } from '@/hooks/use-config';
|
|||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
|
type Tab =
|
||||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
| 'account'
|
||||||
|
| 'language'
|
||||||
|
| 'notifications'
|
||||||
|
| 'appearance'
|
||||||
|
| 'layout'
|
||||||
|
| 'reading'
|
||||||
|
| 'composing'
|
||||||
|
| 'identities'
|
||||||
|
| 'vacation'
|
||||||
|
| 'filters'
|
||||||
|
| 'templates'
|
||||||
|
| 'folders'
|
||||||
|
| 'keywords'
|
||||||
|
| 'security'
|
||||||
|
| 'encryption'
|
||||||
|
| 'content_senders'
|
||||||
|
| 'calendar'
|
||||||
|
| 'contacts'
|
||||||
|
| 'files'
|
||||||
|
| 'sidebar_apps'
|
||||||
|
| 'about_data'
|
||||||
|
| 'themes'
|
||||||
|
| 'plugins'
|
||||||
|
| 'debug';
|
||||||
|
|
||||||
|
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
id: Tab;
|
id: Tab;
|
||||||
@@ -74,28 +109,54 @@ interface TabDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabIcons: Record<Tab, LucideIcon> = {
|
const tabIcons: Record<Tab, LucideIcon> = {
|
||||||
appearance: Palette,
|
|
||||||
email: Mail,
|
|
||||||
notifications: Bell,
|
|
||||||
account: User,
|
account: User,
|
||||||
security: Shield,
|
language: Languages,
|
||||||
|
notifications: Bell,
|
||||||
|
appearance: Palette,
|
||||||
|
layout: LayoutGrid,
|
||||||
|
reading: BookOpen,
|
||||||
|
composing: PenLine,
|
||||||
identities: UserPen,
|
identities: UserPen,
|
||||||
encryption: KeyRound,
|
|
||||||
vacation: PalmtreeIcon,
|
vacation: PalmtreeIcon,
|
||||||
calendar: Calendar,
|
|
||||||
contacts: BookUser,
|
|
||||||
filters: Filter,
|
filters: Filter,
|
||||||
templates: FileText,
|
templates: FileText,
|
||||||
folders: FolderOpen,
|
folders: FolderOpen,
|
||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
|
security: Shield,
|
||||||
|
encryption: KeyRound,
|
||||||
|
content_senders: EyeOff,
|
||||||
|
calendar: Calendar,
|
||||||
|
contacts: BookUser,
|
||||||
files: HardDrive,
|
files: HardDrive,
|
||||||
sidebar_apps: PanelLeftClose,
|
sidebar_apps: PanelLeftClose,
|
||||||
|
about_data: Info,
|
||||||
themes: Palette,
|
themes: Palette,
|
||||||
plugins: Puzzle,
|
plugins: Puzzle,
|
||||||
advanced: Wrench,
|
debug: Bug,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
|
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||||
|
|
||||||
|
// Map legacy tab IDs to current ones; runs once on read of localStorage.
|
||||||
|
const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||||
|
email: 'reading',
|
||||||
|
advanced: 'about_data',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readPersistedTab(): Tab {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
|
if (!saved) return 'appearance';
|
||||||
|
if (saved in LEGACY_TAB_MAP) {
|
||||||
|
const migrated = LEGACY_TAB_MAP[saved];
|
||||||
|
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
return saved as Tab;
|
||||||
|
} catch {
|
||||||
|
return 'appearance';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -107,13 +168,7 @@ export default function SettingsPage() {
|
|||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
|
||||||
if (saved) return saved as Tab;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return 'appearance';
|
|
||||||
});
|
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
@@ -124,21 +179,28 @@ export default function SettingsPage() {
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const dragStartWidth = useRef(256);
|
const dragStartWidth = useRef(256);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
// Listen for tab change events from child components
|
// Listen for tab change events from child components (with legacy migration)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
const tab = (e as CustomEvent).detail as Tab;
|
const raw = (e as CustomEvent).detail as string;
|
||||||
if (tab) {
|
if (!raw) return;
|
||||||
setActiveTab(tab);
|
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
|
||||||
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
setActiveTab(tab);
|
||||||
}
|
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
window.addEventListener('settings-tab-change', handler);
|
window.addEventListener('settings-tab-change', handler);
|
||||||
return () => window.removeEventListener('settings-tab-change', handler);
|
return () => window.removeEventListener('settings-tab-change', handler);
|
||||||
@@ -161,25 +223,41 @@ export default function SettingsPage() {
|
|||||||
const supportsFiles = client?.supportsFiles() ?? false;
|
const supportsFiles = client?.supportsFiles() ?? false;
|
||||||
|
|
||||||
const tabs: TabDef[] = [
|
const tabs: TabDef[] = [
|
||||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
|
// General
|
||||||
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
|
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||||
|
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, 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 }] : []),
|
// Appearance
|
||||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
|
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||||
...(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 }] : []),
|
// Mail
|
||||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
|
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
|
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||||
|
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||||
|
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||||
|
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||||
|
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||||
|
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||||
|
|
||||||
|
// Privacy & Security
|
||||||
|
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||||
|
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
||||||
|
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||||
|
|
||||||
|
// Apps
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
...(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' },
|
{ 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 }] : []),
|
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
|
|
||||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
|
// Advanced
|
||||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||||
|
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||||
|
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||||
|
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
@@ -191,6 +269,10 @@ export default function SettingsPage() {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => g.items.length > 0);
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
|
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
||||||
|
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
||||||
|
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
||||||
|
|
||||||
const handleTabSelect = (tabId: Tab) => {
|
const handleTabSelect = (tabId: Tab) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||||
@@ -199,39 +281,42 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
|
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||||
|
|
||||||
const renderTabContent = () => (
|
const renderTabContent = () => (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||||
{activeTab === 'email' && <EmailSettings />}
|
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||||
{activeTab === 'notifications' && <NotificationSettings />}
|
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||||
{activeTab === 'account' && <AccountSettings />}
|
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
|
||||||
{activeTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||||
{activeTab === 'identities' && <IdentitySettings />}
|
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||||
{activeTab === 'encryption' && <SmimeSettings />}
|
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||||
{activeTab === 'vacation' && <VacationSettings />}
|
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||||
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||||
{activeTab === 'filters' && <FilterSettings />}
|
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||||
{activeTab === 'templates' && <TemplateSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
{activeTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{activeTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||||
{activeTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||||
{activeTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||||
|
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
|
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||||
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mobile layout
|
// Mobile layout
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
// Mobile: show content view
|
|
||||||
if (mobileShowContent) {
|
if (mobileShowContent) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-background">
|
<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">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -244,14 +329,12 @@ export default function SettingsPage() {
|
|||||||
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="bg-card border border-border rounded-lg p-4">
|
<div className="bg-card border border-border rounded-lg p-4">
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -264,10 +347,8 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: show tab list
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-background">
|
<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">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -283,7 +364,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab list */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
@@ -319,7 +399,6 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<div className="border-t border-border px-5 py-3">
|
<div className="border-t border-border px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -331,7 +410,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -347,7 +425,6 @@ export default function SettingsPage() {
|
|||||||
// Desktop layout
|
// Desktop layout
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh 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)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -366,7 +443,6 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
{!inlineApp && (
|
{!inlineApp && (
|
||||||
<>
|
<>
|
||||||
{/* Settings Sidebar */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary flex flex-col",
|
"border-r border-border bg-secondary flex flex-col",
|
||||||
@@ -374,7 +450,6 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
style={{ width: `${settingsSidebarWidth}px` }}
|
style={{ width: `${settingsSidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b border-border">
|
<div className="p-4 border-b border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -387,7 +462,6 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
<div className="px-2 space-y-0.5">
|
<div className="px-2 space-y-0.5">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
@@ -406,14 +480,14 @@ export default function SettingsPage() {
|
|||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'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
|
effectiveActiveTab === tab.id
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={cn(
|
<Icon className={cn(
|
||||||
'w-4 h-4 shrink-0',
|
'w-4 h-4 shrink-0',
|
||||||
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
)} />
|
)} />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.experimental && (
|
{tab.experimental && (
|
||||||
@@ -430,7 +504,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar resize handle */}
|
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||||
@@ -441,10 +514,8 @@ export default function SettingsPage() {
|
|||||||
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Settings Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto p-8">
|
<div className="max-w-3xl mx-auto p-8">
|
||||||
{/* Page Header */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2.5 mb-2">
|
<div className="flex items-center gap-2.5 mb-2">
|
||||||
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
||||||
@@ -452,7 +523,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Tab Content */}
|
|
||||||
<div className="bg-card border border-border rounded-lg p-6">
|
<div className="bg-card border border-border rounded-lg p-6">
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+170
-4
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -19,7 +20,7 @@ export default function AdminAuthPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) setConfig(await res.json());
|
if (res.ok) setConfig(await res.json());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,7 @@ export default function AdminAuthPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -57,7 +58,7 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
@@ -68,6 +69,58 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [setupRunning, setSetupRunning] = useState(false);
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const [setupOrigin, setSetupOrigin] = useState('');
|
||||||
|
const [setupIssuer, setSetupIssuer] = useState('');
|
||||||
|
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
||||||
|
|
||||||
|
function openSetupDialog() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
||||||
|
setSetupOrigin(origin);
|
||||||
|
setSetupIssuer(jmapUrl || origin);
|
||||||
|
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
||||||
|
setSetupOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAutoSetup() {
|
||||||
|
setSetupRunning(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/oauth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||||
|
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||||
|
oauthOnly: setupOauthOnly,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
||||||
|
});
|
||||||
|
setEdits({});
|
||||||
|
setSetupOpen(false);
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
||||||
|
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
|
||||||
|
} finally {
|
||||||
|
setSetupRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||||
|
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||||
|
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -99,6 +152,119 @@ export default function AdminAuthPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-setup */}
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
||||||
|
Requires your Stalwart account to have admin permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openSetupDialog}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-setup dialog */}
|
||||||
|
{setupOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="oauth-setup-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Webmail origin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-origin"
|
||||||
|
type="url"
|
||||||
|
value={setupOrigin}
|
||||||
|
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://webmail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||||
|
</p>
|
||||||
|
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Stalwart issuer URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-issuer"
|
||||||
|
type="url"
|
||||||
|
value={setupIssuer}
|
||||||
|
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://mail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||||
|
</p>
|
||||||
|
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setupOauthOnly}
|
||||||
|
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-input"
|
||||||
|
disabled={setupRunning}
|
||||||
|
/>
|
||||||
|
Also enable “OAuth only” (hide password login)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setSetupOpen(false)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoSetup}
|
||||||
|
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* OAuth */}
|
{/* OAuth */}
|
||||||
<Section title="OAuth / OpenID Connect">
|
<Section title="OAuth / OpenID Connect">
|
||||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -38,7 +39,7 @@ export default function AdminBrandingPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) setConfig(await res.json());
|
if (res.ok) setConfig(await res.json());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ export default function AdminBrandingPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -83,7 +84,7 @@ export default function AdminBrandingPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('slot', slot);
|
formData.append('slot', slot);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -112,7 +113,7 @@ export default function AdminBrandingPage() {
|
|||||||
async function handleDeleteUpload(slot: string) {
|
async function handleDeleteUpload(slot: string) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slot }),
|
body: JSON.stringify({ slot }),
|
||||||
@@ -133,7 +134,7 @@ export default function AdminBrandingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Lock } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
export default function ChangePasswordPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,7 +29,7 @@ export default function ChangePasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/change-password', {
|
const res = await apiFetch('/api/admin/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
|||||||
+59
-49
@@ -27,6 +27,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
|||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
@@ -64,6 +65,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
@@ -72,57 +74,62 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname !== '/admin/login') {
|
if (pathname === '/admin/login') return;
|
||||||
checkAuth();
|
let cancelled = false;
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
function getJmapHeaders(): Record<string, string> {
|
async function checkAuth() {
|
||||||
return getActiveAccountSlotHeaders();
|
try {
|
||||||
}
|
const jmapHeaders = getActiveAccountSlotHeaders();
|
||||||
|
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||||
|
const data = await res.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
async function checkAuth() {
|
const stalwartAdmin = data.stalwartAdmin === true;
|
||||||
try {
|
setIsStalwartAdmin(stalwartAdmin);
|
||||||
const jmapHeaders = getJmapHeaders();
|
|
||||||
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const stalwartAdmin = data.stalwartAdmin === true;
|
// If neither password-based admin nor Stalwart admin, redirect away
|
||||||
setIsStalwartAdmin(stalwartAdmin);
|
if (!data.enabled && !stalwartAdmin) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If neither password-based admin nor Stalwart admin, redirect away
|
if (data.authenticated) {
|
||||||
if (!data.enabled && !stalwartAdmin) {
|
|
||||||
router.replace('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.authenticated) {
|
|
||||||
setAuthenticated(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Stalwart admin but not yet authenticated, auto-login
|
|
||||||
if (stalwartAdmin) {
|
|
||||||
const loginRes = await fetch('/api/admin/auth', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
|
||||||
body: JSON.stringify({ stalwartAuth: true }),
|
|
||||||
});
|
|
||||||
if (loginRes.ok) {
|
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
router.replace('/admin/login');
|
// If Stalwart admin but not yet authenticated, auto-login
|
||||||
} catch {
|
if (stalwartAdmin) {
|
||||||
router.replace('/admin/login');
|
const loginRes = await apiFetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||||
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (loginRes.ok) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await loginRes.json().catch(() => ({}));
|
||||||
|
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
|
||||||
|
setAuthenticated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace('/admin/login');
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
|
||||||
|
setAuthenticated(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
checkAuth();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
router.replace('/admin/login');
|
router.replace('/admin/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +138,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated === null) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
||||||
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail */}
|
{/* Slim webmail nav rail */}
|
||||||
@@ -268,7 +267,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
{children}
|
{authError ? (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
<p className="font-medium">Admin authentication failed</p>
|
||||||
|
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||||
|
</div>
|
||||||
|
) : authenticated === null ? (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||||
|
Loading admin panel…
|
||||||
|
</div>
|
||||||
|
) : authenticated ? (
|
||||||
|
children
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +22,7 @@ export default function AdminLoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/auth', {
|
const res = await apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
export default function AdminLogsPage() {
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
@@ -17,7 +18,7 @@ export default function AdminLogsPage() {
|
|||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (actionFilter) params.set('action', actionFilter);
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/audit?${params}`);
|
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setEntries(data.entries || []);
|
setEntries(data.entries || []);
|
||||||
@@ -138,7 +139,7 @@ export default function AdminLogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDetail(detail: Record<string, unknown>): string {
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
if (!detail || Object.keys(detail).length === 0) return '—';
|
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||||
if (detail.reason) return String(detail.reason);
|
if (detail.reason) return String(detail.reason);
|
||||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||||
if (detail.changes && Array.isArray(detail.changes)) {
|
if (detail.changes && Array.isArray(detail.changes)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -57,7 +58,7 @@ export default function AdminMarketplacePage() {
|
|||||||
params.set('perPage', String(perPage));
|
params.set('perPage', String(perPage));
|
||||||
params.set('sort', 'newest');
|
params.set('sort', 'newest');
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/marketplace?${params}`);
|
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
setError(data.error || 'Failed to connect to extension directory');
|
setError(data.error || 'Failed to connect to extension directory');
|
||||||
@@ -95,7 +96,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/marketplace', {
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -116,7 +117,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(null);
|
setInstalling(null);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface AdminStatus {
|
interface AdminStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -38,13 +39,13 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
async function fetchDashboardData() {
|
async function fetchDashboardData() {
|
||||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||||
fetch('/api/admin/auth'),
|
apiFetch('/api/admin/auth'),
|
||||||
fetch('/api/admin/audit?limit=10'),
|
apiFetch('/api/admin/audit?limit=10'),
|
||||||
fetch('/api/config'),
|
apiFetch('/api/config'),
|
||||||
fetch('/api/admin/config'),
|
apiFetch('/api/admin/config'),
|
||||||
fetch('/api/admin/plugins').catch(() => null),
|
apiFetch('/api/admin/plugins').catch(() => null),
|
||||||
fetch('/api/admin/themes').catch(() => null),
|
apiFetch('/api/admin/themes').catch(() => null),
|
||||||
fetch('/api/admin/policy').catch(() => null),
|
apiFetch('/api/admin/policy').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statusRes.ok) setStatus(await statusRes.json());
|
if (statusRes.ok) setStatus(await statusRes.json());
|
||||||
@@ -75,7 +76,7 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
if (configData?.jmapServerUrl) {
|
||||||
try {
|
try {
|
||||||
const jmapRes = await fetch('/api/config');
|
const jmapRes = await apiFetch('/api/config');
|
||||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||||
} catch {
|
} catch {
|
||||||
setJmapHealth('error');
|
setJmapHealth('error');
|
||||||
@@ -98,8 +99,8 @@ export default function AdminDashboardPage() {
|
|||||||
setWarnings(w);
|
setWarnings(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jmapUrl = config?.jmapServerUrl || '—';
|
const jmapUrl = config?.jmapServerUrl || '-';
|
||||||
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl space-y-8">
|
<div className="max-w-3xl space-y-8">
|
||||||
@@ -126,9 +127,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Server Info */}
|
{/* Server Info */}
|
||||||
<SettingsSection title="Server" description="Application and connection details">
|
<SettingsSection title="Server" description="Application and connection details">
|
||||||
<SettingItem label="Application">
|
<SettingItem label="Application">
|
||||||
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="JMAP Connection">
|
<SettingItem label="JMAP Connection">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigField {
|
interface ConfigField {
|
||||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
@@ -68,8 +69,8 @@ export default function PluginConfigPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [pluginsRes, configRes] = await Promise.all([
|
const [pluginsRes, configRes] = await Promise.all([
|
||||||
fetch('/api/admin/plugins'),
|
apiFetch('/api/admin/plugins'),
|
||||||
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (pluginsRes.ok) {
|
if (pluginsRes.ok) {
|
||||||
@@ -117,7 +118,7 @@ export default function PluginConfigPage() {
|
|||||||
|
|
||||||
// Delete if clearing a non-required field
|
// Delete if clearing a non-required field
|
||||||
if (!newVal && !field.required) {
|
if (!newVal && !field.required) {
|
||||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
@@ -130,7 +131,7 @@ export default function PluginConfigPage() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key, value }),
|
body: JSON.stringify({ key, value }),
|
||||||
@@ -230,7 +231,7 @@ export default function PluginConfigPage() {
|
|||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">— Select —</option>
|
<option value="">- Select -</option>
|
||||||
{field.options.map(opt => (
|
{field.options.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface PluginEntry {
|
interface PluginEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,7 +35,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy(data);
|
setPolicy(data);
|
||||||
@@ -73,7 +74,7 @@ export default function AdminPluginsPage() {
|
|||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
@@ -95,7 +96,7 @@ export default function AdminPluginsPage() {
|
|||||||
async function fetchPlugins() {
|
async function fetchPlugins() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/plugins');
|
const res = await apiFetch('/api/admin/plugins');
|
||||||
if (res.ok) setPlugins(await res.json());
|
if (res.ok) setPlugins(await res.json());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -113,7 +114,7 @@ export default function AdminPluginsPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -136,7 +137,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
async function togglePlugin(id: string, enabled: boolean) {
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, enabled }),
|
body: JSON.stringify({ id, enabled }),
|
||||||
@@ -156,7 +157,7 @@ export default function AdminPluginsPage() {
|
|||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
if (forceEnabled) body.enabled = true;
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -190,7 +191,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const p of disabled) {
|
for (const p of disabled) {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
@@ -216,7 +217,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const p of enabled) {
|
for (const p of enabled) {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
@@ -236,7 +237,7 @@ export default function AdminPluginsPage() {
|
|||||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Save, Loader2, Lock } from 'lucide-react';
|
import { Save, Loader2, Lock } from 'lucide-react';
|
||||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
// Feature gates managed on their own admin pages (excluded from this list)
|
// Feature gates managed on their own admin pages (excluded from this list)
|
||||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||||
@@ -54,7 +55,7 @@ export default function AdminPolicyPage() {
|
|||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy(data);
|
setPolicy(data);
|
||||||
@@ -106,7 +107,7 @@ export default function AdminPolicyPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -21,7 +22,7 @@ export default function AdminSettingsPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setConfig(await res.json());
|
setConfig(await res.json());
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ export default function AdminSettingsPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -61,7 +62,7 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
@@ -121,7 +122,6 @@ export default function AdminSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
|
||||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
const BUILTIN_THEME_OPTIONS = [
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
{ id: 'builtin-nord', name: 'Nord' },
|
||||||
@@ -38,7 +39,7 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy({
|
setPolicy({
|
||||||
@@ -122,7 +123,7 @@ export default function AdminThemesPage() {
|
|||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
@@ -144,7 +145,7 @@ export default function AdminThemesPage() {
|
|||||||
async function fetchThemes() {
|
async function fetchThemes() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/themes');
|
const res = await apiFetch('/api/admin/themes');
|
||||||
if (res.ok) setThemes(await res.json());
|
if (res.ok) setThemes(await res.json());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -162,7 +163,7 @@ export default function AdminThemesPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -185,7 +186,7 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
async function toggleTheme(id: string, enabled: boolean) {
|
async function toggleTheme(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, enabled }),
|
body: JSON.stringify({ id, enabled }),
|
||||||
@@ -204,7 +205,7 @@ export default function AdminThemesPage() {
|
|||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
if (forceEnabled) body.enabled = true;
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -237,7 +238,7 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const t of disabled) {
|
for (const t of disabled) {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: t.id, enabled: true }),
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
@@ -262,7 +263,7 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const t of enabled) {
|
for (const t of enabled) {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: t.id, enabled: false }),
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
@@ -282,7 +283,7 @@ export default function AdminThemesPage() {
|
|||||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Stalwart error response to extract meaningful error message
|
|
||||||
*/
|
|
||||||
function parseStalwartError(responseText: string): string {
|
|
||||||
try {
|
|
||||||
const error = JSON.parse(responseText);
|
|
||||||
if (error.detail) return error.detail;
|
|
||||||
if (error.error) return error.error;
|
|
||||||
return `HTTP ${error.status || 'Error'}`;
|
|
||||||
} catch {
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
const detail = parseStalwartError(text);
|
|
||||||
logger.warn('Stalwart auth info failed', { status: response.status, detail });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: detail || 'Failed to fetch auth info' },
|
|
||||||
{ 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Stalwart error response to extract meaningful error message
|
|
||||||
*/
|
|
||||||
function parseStalwartError(responseText: string): string {
|
|
||||||
try {
|
|
||||||
const error = JSON.parse(responseText);
|
|
||||||
if (error.detail) return error.detail;
|
|
||||||
if (error.error) return error.error;
|
|
||||||
return `HTTP ${error.status || 'Error'}`;
|
|
||||||
} catch {
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
const detail = parseStalwartError(text);
|
|
||||||
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: detail || 'Failed to fetch crypto info' },
|
|
||||||
{ 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,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/account/stalwart/jmap
|
||||||
|
*
|
||||||
|
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
|
||||||
|
* context so the browser does not need access to the user's credentials.
|
||||||
|
*
|
||||||
|
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
|
||||||
|
*
|
||||||
|
* In Stalwart 0.16 all management operations (password change, app
|
||||||
|
* passwords, API keys, account settings, etc.) are exposed as JMAP
|
||||||
|
* methods under the `x:` namespace on the same endpoint.
|
||||||
|
*/
|
||||||
|
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.text();
|
||||||
|
|
||||||
|
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': creds.authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
return new NextResponse(responseText, {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Stalwart JMAP passthrough error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
|
||||||
|
|
||||||
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
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
if (creds.hasSessionCookie) {
|
|
||||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
|
||||||
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (creds.authHeader.startsWith('Basic ')) {
|
|
||||||
setStalwartAuthContextInStore(cookieStore, creds.slot, {
|
|
||||||
serverUrl: creds.serverUrl,
|
|
||||||
username: creds.username,
|
|
||||||
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Stalwart error response to extract meaningful error message
|
|
||||||
*/
|
|
||||||
function parseStalwartError(responseText: string): string {
|
|
||||||
try {
|
|
||||||
const error = JSON.parse(responseText);
|
|
||||||
if (error.detail) return error.detail;
|
|
||||||
if (error.error) return error.error;
|
|
||||||
return `HTTP ${error.status || 'Error'}`;
|
|
||||||
} catch {
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
const detail = parseStalwartError(text);
|
|
||||||
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: detail || 'Failed to fetch principal' },
|
|
||||||
{ 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/account/stalwart/probe
|
|
||||||
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ isStalwart: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': creds.authHeader },
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json({ isStalwart: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
|
|
||||||
|
|
||||||
return NextResponse.json({ isStalwart });
|
|
||||||
} catch {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
return NextResponse.json({ isStalwart: false });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ isStalwart: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Binary file not shown.
@@ -14,7 +14,7 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||||
*
|
*
|
||||||
* This endpoint is public (no admin auth) so browsers can load images.
|
* This endpoint is public (no admin auth) so browsers can load images.
|
||||||
* Only files in the branding directory are served; directory traversal is prevented.
|
* Only files in the branding directory are served; directory traversal is prevented.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/branding — Upload a branding image file
|
* POST /api/admin/branding - Upload a branding image file
|
||||||
*
|
*
|
||||||
* Expects multipart/form-data with:
|
* Expects multipart/form-data with:
|
||||||
* - file: the image file
|
* - file: the image file
|
||||||
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/branding — Remove an uploaded branding file
|
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||||
*
|
*
|
||||||
* Expects JSON body: { slot: string }
|
* Expects JSON body: { slot: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/change-password — Change admin password
|
* POST /api/admin/change-password - Change admin password
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/config — Get full config with sources (admin-protected)
|
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
type ServerTheme,
|
type ServerTheme,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
invalidateFrameOriginsCache,
|
||||||
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
@@ -17,7 +21,7 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
|||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace — Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
* Proxies to the extension directory API
|
* Proxies to the extension directory API
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -75,7 +79,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/marketplace — Install an extension from the directory
|
* POST /api/admin/marketplace - Install an extension from the directory
|
||||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
|
|||||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugins may declare iframe origins they need for embedded content.
|
||||||
|
// Anything that doesn't pass strict origin validation is silently
|
||||||
|
// dropped — the plugin still installs, but those origins are not
|
||||||
|
// added to the host CSP.
|
||||||
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||||
|
? (manifest.frameOrigins as unknown[]).filter(
|
||||||
|
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (droppedFrameOrigins.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: (manifest.id as string) || slug,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, plugin, warnings });
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { locales as ALL_LOCALES } from '@/i18n/routing';
|
||||||
|
|
||||||
|
const CLIENT_ID = 'bulwark-webmail';
|
||||||
|
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
|
||||||
|
const JMAP_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
interface JmapMethodCall {
|
||||||
|
using: string[];
|
||||||
|
methodCalls: Array<[string, Record<string, unknown>, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JmapMethodResponse {
|
||||||
|
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jmapCall(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
body: JmapMethodCall,
|
||||||
|
): Promise<JmapMethodResponse> {
|
||||||
|
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<JmapMethodResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStalwartAccountId(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': authHeader },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const session = await res.json() as { primaryAccounts?: Record<string, string> };
|
||||||
|
return session.primaryAccounts?.['urn:stalwart:jmap']
|
||||||
|
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
|
||||||
|
?? Object.values(session.primaryAccounts ?? {})[0]
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
|
||||||
|
const out: Record<string, true> = {};
|
||||||
|
for (const loc of localeList) {
|
||||||
|
out[`${origin}/${loc}/auth/callback`] = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupRequestBody {
|
||||||
|
origin?: string;
|
||||||
|
issuerUrl?: string;
|
||||||
|
locales?: string[];
|
||||||
|
oauthOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidOriginUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\/[^/]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json() as SetupRequestBody;
|
||||||
|
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
|
||||||
|
if (!isValidOriginUrl(origin)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
|
||||||
|
if (!isValidOriginUrl(issuerUrl)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const localeList = Array.isArray(body.locales) && body.locales.length > 0
|
||||||
|
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
|
||||||
|
: Array.from(ALL_LOCALES);
|
||||||
|
if (localeList.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const oauthOnly = body.oauthOnly === true;
|
||||||
|
|
||||||
|
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
|
||||||
|
if (!accountId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Could not resolve Stalwart account from JMAP session.' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||||
|
methodCalls: [[
|
||||||
|
'x:OAuthClient/query',
|
||||||
|
{ accountId, filter: { clientId: CLIENT_ID } },
|
||||||
|
'0',
|
||||||
|
]],
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryEntry = queryRes.methodResponses?.[0];
|
||||||
|
if (!queryEntry || queryEntry[0] === 'error') {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Stalwart denied OAuthClient/query — your Stalwart account likely lacks admin permissions.',
|
||||||
|
detail: queryEntry?.[1],
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
|
||||||
|
|
||||||
|
const secret = randomBytes(32).toString('base64url');
|
||||||
|
const redirectUris = buildRedirectUris(origin, localeList);
|
||||||
|
|
||||||
|
let setArgs: Record<string, unknown>;
|
||||||
|
let action: 'created' | 'updated';
|
||||||
|
if (existingIds.length > 0) {
|
||||||
|
const targetId = existingIds[0];
|
||||||
|
action = 'updated';
|
||||||
|
setArgs = {
|
||||||
|
accountId,
|
||||||
|
update: {
|
||||||
|
[targetId]: {
|
||||||
|
secret,
|
||||||
|
redirectUris,
|
||||||
|
description: CLIENT_DESCRIPTION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
action = 'created';
|
||||||
|
setArgs = {
|
||||||
|
accountId,
|
||||||
|
create: {
|
||||||
|
new: {
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
description: CLIENT_DESCRIPTION,
|
||||||
|
secret,
|
||||||
|
redirectUris,
|
||||||
|
contacts: { [creds.username]: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||||
|
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const setEntry = setRes.methodResponses?.[0];
|
||||||
|
if (!setEntry || setEntry[0] === 'error') {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Stalwart denied OAuthClient/set — admin permissions required.',
|
||||||
|
detail: setEntry?.[1],
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
const setBody = setEntry[1] as {
|
||||||
|
notCreated?: Record<string, unknown>;
|
||||||
|
notUpdated?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
oauthEnabled: true,
|
||||||
|
oauthClientId: CLIENT_ID,
|
||||||
|
oauthClientSecret: secret,
|
||||||
|
oauthIssuerUrl: issuerUrl,
|
||||||
|
};
|
||||||
|
if (oauthOnly) updates.oauthOnly = true;
|
||||||
|
await configManager.setAdminConfig(updates);
|
||||||
|
|
||||||
|
await auditLog('admin.oauth_setup', {
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
|
redirectUriCount: localeList.length,
|
||||||
|
oauthOnly,
|
||||||
|
}, ip);
|
||||||
|
|
||||||
|
logger.info('Admin OAuth setup', {
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
|
locales: localeList.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuerUrl,
|
||||||
|
redirectUriCount: localeList.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
*
|
*
|
||||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||||
* Only serves plugins that exist in the registry and are enabled.
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/a
|
|||||||
import { requireAdminAuth } from '@/lib/admin/session';
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
|
||||||
*
|
*
|
||||||
* Returns the full config object for admin-configured plugin settings.
|
* Returns the full config object for admin-configured plugin settings.
|
||||||
* This endpoint is accessible from the client-side plugin API.
|
* This endpoint is accessible from the client-side plugin API.
|
||||||
@@ -35,7 +35,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/plugins/[id]/config — Set a config key
|
* PUT /api/admin/plugins/[id]/config - Set a config key
|
||||||
*
|
*
|
||||||
* Body: { key: string, value: unknown }
|
* Body: { key: string, value: unknown }
|
||||||
* Requires admin authentication (checked via admin session).
|
* Requires admin authentication (checked via admin session).
|
||||||
@@ -83,7 +83,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
* DELETE /api/admin/plugins/[id]/config - Delete a config key
|
||||||
*
|
*
|
||||||
* Body: { key: string }
|
* Body: { key: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
deletePlugin as removePlugin,
|
deletePlugin as removePlugin,
|
||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
invalidateFrameOriginsCache,
|
||||||
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
// Server-side extraction using the same validation logic
|
// Server-side extraction using the same validation logic
|
||||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||||
@@ -23,7 +27,7 @@ const SUSPICIOUS_JS_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins — List all admin-managed plugins
|
* GET /api/admin/plugins - List all admin-managed plugins
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +45,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: manifest.id as string,
|
id: manifest.id as string,
|
||||||
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ plugin });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,7 +191,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/plugins — Update plugin metadata (enable/disable)
|
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable/disable changes the set of plugins contributing frame origins.
|
||||||
|
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
|
}
|
||||||
|
|
||||||
await auditLog('plugin.update', { id, ...updates }, ip);
|
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||||
return NextResponse.json({ plugin: updated });
|
return NextResponse.json({ plugin: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -218,7 +233,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins — Remove a plugin
|
* DELETE /api/admin/plugins - Remove a plugin
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('plugin.delete', { id }, ip);
|
await auditLog('plugin.delete', { id }, ip);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +22,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/stalwart-check
|
|
||||||
* Check if the currently logged-in user is a Stalwart admin.
|
|
||||||
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probe an admin-only endpoint: listing principals requires admin privileges.
|
|
||||||
// Use limit=1 to minimize payload.
|
|
||||||
const url = `${creds.apiUrl}/api/principal?limit=1`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': creds.authHeader },
|
|
||||||
});
|
|
||||||
|
|
||||||
const isStalwartAdmin = response.ok;
|
|
||||||
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
|
|
||||||
|
|
||||||
return NextResponse.json({ isStalwartAdmin }, {
|
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart admin check error', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown',
|
|
||||||
});
|
|
||||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
|||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes — List all admin-managed themes
|
* GET /api/admin/themes - List all admin-managed themes
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +32,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/themes — Upload and install a theme ZIP
|
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/themes — Remove a theme
|
* DELETE /api/admin/themes - Remove a theme
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
clearStalwartAuthContextInStore,
|
clearStalwartAuthContextInStore,
|
||||||
setStalwartAuthContextInStore,
|
setStalwartAuthContextInStore,
|
||||||
} from '@/lib/stalwart/auth-context';
|
} from '@/lib/stalwart/auth-context';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -25,7 +26,9 @@ function getSlot(request: NextRequest): number {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||||
|
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
|
||||||
|
if (oauthEnabled && oauthOnly) {
|
||||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +98,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT — retrieve full credentials (including password) for session restoration.
|
* PUT - retrieve full credentials (including password) for session restoration.
|
||||||
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||||
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
|
|||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -66,7 +67,7 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network error — endpoint not reachable
|
// Network error - endpoint not reachable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +114,8 @@ async function attemptAllStrategies(
|
|||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,89 +1,9 @@
|
|||||||
import { lookup } from 'node:dns/promises';
|
|
||||||
import { BlockList, isIP } from 'node:net';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
const blockedAddressRanges = new BlockList();
|
|
||||||
blockedAddressRanges.addAddress('0.0.0.0');
|
|
||||||
blockedAddressRanges.addAddress('127.0.0.1');
|
|
||||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
|
||||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
|
||||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
|
||||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
|
||||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
|
||||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
|
||||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
|
||||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
|
||||||
|
|
||||||
function normalizeHostname(hostname: string): string {
|
|
||||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBlockedIpAddress(hostname: string): boolean {
|
|
||||||
const normalized = normalizeHostname(hostname);
|
|
||||||
const family = isIP(normalized);
|
|
||||||
if (family === 4) {
|
|
||||||
return blockedAddressRanges.check(normalized, 'ipv4');
|
|
||||||
}
|
|
||||||
if (family === 6) {
|
|
||||||
return blockedAddressRanges.check(normalized, 'ipv6');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(urlString);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = normalizeHostname(url.hostname);
|
|
||||||
|
|
||||||
// Block private/internal hostnames
|
|
||||||
if (
|
|
||||||
hostname === 'localhost' ||
|
|
||||||
hostname.endsWith('.localhost') ||
|
|
||||||
hostname.endsWith('.local') ||
|
|
||||||
hostname.endsWith('.internal') ||
|
|
||||||
hostname.endsWith('.arpa') ||
|
|
||||||
hostname.endsWith('.localdomain')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block URLs with credentials
|
|
||||||
if (url.username || url.password) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBlockedIpAddress(hostname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isIP(hostname)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
|
||||||
if (records.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return records.every((record) => !isBlockedIpAddress(record.address));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { url?: string };
|
let body: { url?: string };
|
||||||
try {
|
try {
|
||||||
@@ -98,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isValidExternalUrl(url))) {
|
if (!(await isPublicHttpUrl(url))) {
|
||||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
let response: Response | undefined;
|
let response: Response | undefined;
|
||||||
|
|
||||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
if (!(await isValidExternalUrl(currentUrl))) {
|
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -131,7 +51,6 @@ export async function POST(request: NextRequest) {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||||
}
|
}
|
||||||
// Resolve relative redirects
|
|
||||||
currentUrl = new URL(location, currentUrl).toString();
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
|
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||||
*
|
*
|
||||||
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||||
* No admin auth required — this is how regular users receive plugins/themes.
|
* No admin auth required - this is how regular users receive plugins/themes.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
|
// Cache resized images in memory to avoid reprocessing on every request
|
||||||
|
const cache = new Map<number, Blob>();
|
||||||
|
|
||||||
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
|
// Absolute URL (http/https)
|
||||||
|
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||||
|
const res = await fetch(iconUrl);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path relative to public/ directory
|
||||||
|
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||||
|
return readFile(publicPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ size: string }> }
|
||||||
|
) {
|
||||||
|
const { size: sizeParam } = await params;
|
||||||
|
const size = parseInt(sizeParam, 10);
|
||||||
|
|
||||||
|
if (!VALID_SIZES.has(size)) {
|
||||||
|
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||||
|
if (!iconUrl) {
|
||||||
|
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngHeaders = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cache.has(size)) {
|
||||||
|
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||||
|
const resized = await sharp(sourceBuffer)
|
||||||
|
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
|
new Uint8Array(ab).set(resized);
|
||||||
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
|
cache.set(size, blob);
|
||||||
|
|
||||||
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate PWA icon:', err);
|
||||||
|
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce admin policy — strip locked settings so users can't override them
|
// Enforce admin policy - strip locked settings so users can't override them
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const policy = configManager.getPolicy();
|
||||||
const filteredSettings = { ...settings };
|
const filteredSettings = { ...settings };
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
const baseUrl = creds.serverUrl.replace(/\/$/, '');
|
||||||
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||||
|
|
||||||
// Build headers for the upstream request
|
// Build headers for the upstream request
|
||||||
|
|||||||
+5
-1
@@ -209,10 +209,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.email-content-text a {
|
.email-content-text a {
|
||||||
color: var(--color-primary);
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .email-content-text a {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
description: "Minimalist webmail client using JMAP protocol",
|
description: "Minimalist webmail client using JMAP protocol",
|
||||||
manifest: "/manifest.json",
|
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "black-translucent",
|
statusBarStyle: "black-translucent",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
const appName =
|
||||||
|
process.env.APP_NAME ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
|
"Bulwark Webmail";
|
||||||
|
|
||||||
|
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||||
|
const description =
|
||||||
|
process.env.APP_DESCRIPTION ||
|
||||||
|
"A modern webmail client built for Stalwart Mail Server";
|
||||||
|
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||||
|
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||||
|
|
||||||
|
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||||
|
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||||
|
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||||
|
|
||||||
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
|
? [
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: appName,
|
||||||
|
short_name: shortName,
|
||||||
|
description,
|
||||||
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait-primary",
|
||||||
|
theme_color: themeColor,
|
||||||
|
background_color: backgroundColor,
|
||||||
|
icons,
|
||||||
|
categories: ["productivity"],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
||||||
|
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export function CalendarAgendaView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
}: CalendarAgendaViewProps) {
|
}: CalendarAgendaViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
@@ -157,6 +159,7 @@ export function CalendarAgendaView({
|
|||||||
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onHoverLeave?.()}
|
onMouseLeave={() => onHoverLeave?.()}
|
||||||
|
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
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)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface CalendarDayViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -37,6 +38,7 @@ export function CalendarDayView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -117,6 +119,7 @@ export function CalendarDayView({
|
|||||||
created: t("notifications.event_created"),
|
created: t("notifications.event_created"),
|
||||||
error: t("notifications.event_error"),
|
error: t("notifications.event_error"),
|
||||||
},
|
},
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatHour = (h: number): string => {
|
const formatHour = (h: number): string => {
|
||||||
@@ -157,6 +160,7 @@ export function CalendarDayView({
|
|||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -243,9 +247,11 @@ export function CalendarDayView({
|
|||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const isResizing = resizeVisual?.eventId === ev.id;
|
||||||
|
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||||
|
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
@@ -264,13 +270,24 @@ export function CalendarDayView({
|
|||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("events.resize")}
|
||||||
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||||
|
onPointerMove={handleResizePointerMove}
|
||||||
|
onPointerUp={handleResizePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
data-resize-handle
|
data-resize-handle
|
||||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
aria-label={t("events.resize")}
|
aria-label={t("events.resize")}
|
||||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||||
onPointerMove={handleResizePointerMove}
|
onPointerMove={handleResizePointerMove}
|
||||||
onPointerUp={handleResizePointerUp}
|
onPointerUp={handleResizePointerUp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface CalendarMonthViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -37,6 +38,7 @@ export function CalendarMonthView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -279,6 +281,7 @@ export function CalendarMonthView({
|
|||||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
|
|||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onShareCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateEvent?: (calendar: Calendar) => void;
|
||||||
|
onClearCalendar?: (calendar: Calendar) => void;
|
||||||
|
onDeleteCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateCalendar?: () => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
onEditSubscription?: (subscriptionId: string) => void;
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onShareCalendar,
|
||||||
|
onCreateEvent,
|
||||||
|
onClearCalendar,
|
||||||
|
onDeleteCalendar,
|
||||||
|
onCreateCalendar,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
onEditSubscription,
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tSub = useTranslations("calendar.subscription");
|
const tSub = useTranslations("calendar.subscription");
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
|
|||||||
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||||
const sharedAccountGroups = useMemo(() => {
|
const sharedAccountGroups = useMemo(() => {
|
||||||
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
|
|||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
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) => {
|
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||||
};
|
};
|
||||||
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
|
|||||||
const handleRefreshSubscription = async (subId: string) => {
|
const handleRefreshSubscription = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setRefreshingSubId(subId);
|
setRefreshingSubId(subId);
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await refreshICalSubscription(client, subId);
|
await refreshICalSubscription(client, subId);
|
||||||
toast.success(tSub('refresh_success'));
|
toast.success(tSub('refresh_success'));
|
||||||
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
const handleUnsubscribe = async (subId: string) => {
|
const handleUnsubscribe = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await removeICalSubscription(client, subId);
|
await removeICalSubscription(client, subId);
|
||||||
toast.success(tSub('deleted'));
|
toast.success(tSub('deleted'));
|
||||||
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
|
|||||||
const renderCalendarItem = (cal: Calendar) => {
|
const renderCalendarItem = (cal: Calendar) => {
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
|
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cal.id} className="relative">
|
<div key={cal.id} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||||
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(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||||
"hover:bg-muted"
|
"hover:bg-muted"
|
||||||
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
|
|||||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</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={() => {
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
onEditSubscription?.(sub.id);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
{tSub('edit')}
|
|
||||||
</button>
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderCalendarMenu = () => {
|
||||||
|
const cal = contextMenu.data;
|
||||||
|
if (!cal) return null;
|
||||||
|
|
||||||
|
if (isSubscriptionCalendar(cal.id)) {
|
||||||
|
const sub = getSubscriptionForCalendar(cal.id);
|
||||||
|
if (!sub || !client) return null;
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={tSub('edit')}
|
||||||
|
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={tSub('refresh')}
|
||||||
|
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tSub('unsubscribe')}
|
||||||
|
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||||
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
|
const canChangeColor = !!onColorChange;
|
||||||
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
|
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||||
|
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
{canCreate && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Plus}
|
||||||
|
label={tMgmt('new_event_in_calendar')}
|
||||||
|
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={tMgmt('share')}
|
||||||
|
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canChangeColor && (
|
||||||
|
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||||
|
<div className="px-2 py-1.5 w-[200px]">
|
||||||
|
<CalendarColorPicker
|
||||||
|
value={color}
|
||||||
|
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
|
||||||
|
allowCustom
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContextMenuSubMenu>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canClear && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Eraser}
|
||||||
|
label={tMgmt('clear_events')}
|
||||||
|
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tMgmt('delete')}
|
||||||
|
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{enableCalendarTasks && (
|
{enableCalendarTasks && (
|
||||||
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||||
{t("my_calendars")}
|
{onCreateCalendar ? (
|
||||||
</h3>
|
<button
|
||||||
|
onClick={onCreateCalendar}
|
||||||
|
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||||
|
title={tMgmt('add_calendar')}
|
||||||
|
>
|
||||||
|
{t('my_calendars')}
|
||||||
|
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t('my_calendars')}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{personalCalendars.map(renderCalendarItem)}
|
{personalCalendars.map(renderCalendarItem)}
|
||||||
</div>
|
</div>
|
||||||
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{renderCalendarMenu()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ export function CalendarToolbar({
|
|||||||
{/* ── DESKTOP TOOLBAR ── */}
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
|
||||||
|
{t("views.today")}
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -279,14 +282,14 @@ export function CalendarToolbar({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex border border-border rounded-md overflow-hidden">
|
<div className="flex h-8 border border-border rounded-md overflow-hidden">
|
||||||
{views.map((v) => (
|
{views.map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => onViewModeChange(v)}
|
onClick={() => onViewModeChange(v)}
|
||||||
title={t(`views.${v}_hint`)}
|
title={t(`views.${v}_hint`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
"inline-flex items-center px-3 text-xs font-medium transition-colors",
|
||||||
v === viewMode
|
v === viewMode
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-muted text-muted-foreground"
|
: "hover:bg-muted text-muted-foreground"
|
||||||
@@ -300,7 +303,7 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
{(onImport || onSubscribe) && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<div className="relative" ref={importDropdownRef}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
|
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
||||||
<Upload className="w-4 h-4 mr-1" />
|
<Upload className="w-4 h-4 mr-1" />
|
||||||
{t("import.title")}
|
{t("import.title")}
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
@@ -331,7 +334,7 @@ export function CalendarToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
|
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface CalendarWeekViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
@@ -42,6 +43,7 @@ export function CalendarWeekView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
@@ -181,6 +183,7 @@ export function CalendarWeekView({
|
|||||||
created: t("notifications.event_created"),
|
created: t("notifications.event_created"),
|
||||||
error: t("notifications.event_error"),
|
error: t("notifications.event_error"),
|
||||||
},
|
},
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatHour = (h: number): string => {
|
const formatHour = (h: number): string => {
|
||||||
@@ -242,6 +245,7 @@ export function CalendarWeekView({
|
|||||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -381,9 +385,11 @@ export function CalendarWeekView({
|
|||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const isResizing = resizeVisual?.eventId === ev.id;
|
||||||
|
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||||
|
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
@@ -402,13 +408,24 @@ export function CalendarWeekView({
|
|||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("events.resize")}
|
||||||
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||||
|
onPointerMove={handleResizePointerMove}
|
||||||
|
onPointerUp={handleResizePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
data-resize-handle
|
data-resize-handle
|
||||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
aria-label={t("events.resize")}
|
aria-label={t("events.resize")}
|
||||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||||
onPointerMove={handleResizePointerMove}
|
onPointerMove={handleResizePointerMove}
|
||||||
onPointerUp={handleResizePointerUp}
|
onPointerUp={handleResizePointerUp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
interface CreateCalendarModalProps {
|
||||||
|
client: IJMAPClient;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
|
||||||
|
const t = useTranslations("calendar.management");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [color, setColor] = useState("#3b82f6");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const created = await createCalendar(client, { name: trimmed, color });
|
||||||
|
if (created) {
|
||||||
|
toast.success(t("calendar_created"));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [name, color, client, createCalendar, onClose, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !isSubmitting) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [onClose, isSubmitting]);
|
||||||
|
|
||||||
|
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={() => !isSubmitting && onClose()}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("add_calendar")}
|
||||||
|
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">
|
||||||
|
<CalendarIcon className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
aria-label={tCommon("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<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)}
|
||||||
|
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>
|
||||||
|
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||||
|
</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" />
|
||||||
|
{tCommon("loading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("create")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ interface EventCardProps {
|
|||||||
onClick?: (anchorRect: DOMRect) => void;
|
onClick?: (anchorRect: DOMRect) => void;
|
||||||
onMouseEnter?: (anchorRect: DOMRect) => void;
|
onMouseEnter?: (anchorRect: DOMRect) => void;
|
||||||
onMouseLeave?: () => void;
|
onMouseLeave?: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
continuesBefore?: boolean;
|
continuesBefore?: boolean;
|
||||||
@@ -65,7 +66,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
@@ -109,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
"aria-roledescription": "draggable event",
|
"aria-roledescription": "draggable event",
|
||||||
} : {};
|
} : {};
|
||||||
|
|
||||||
|
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
|
||||||
|
|
||||||
if (variant === "chip") {
|
if (variant === "chip") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -142,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -172,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
Pencil,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
ClipboardCopy,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventContextMenuProps {
|
||||||
|
event: CalendarEvent;
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onExportICS: () => void;
|
||||||
|
onCopyTitle: () => void;
|
||||||
|
onCopyMeetingLink?: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventContextMenu({
|
||||||
|
event,
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onEdit,
|
||||||
|
onDuplicate,
|
||||||
|
onExportICS,
|
||||||
|
onCopyTitle,
|
||||||
|
onCopyMeetingLink,
|
||||||
|
onDelete,
|
||||||
|
}: EventContextMenuProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMeetingLink = !!(
|
||||||
|
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
|
||||||
|
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Download}
|
||||||
|
label={t("events.export_ics")}
|
||||||
|
onClick={handle(onExportICS)}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={ClipboardCopy}
|
||||||
|
label={t("events.copy_title")}
|
||||||
|
onClick={handle(onCopyTitle)}
|
||||||
|
/>
|
||||||
|
{hasMeetingLink && onCopyMeetingLink && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={LinkIcon}
|
||||||
|
label={t("events.copy_link")}
|
||||||
|
onClick={handle(onCopyMeetingLink)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("events.delete")}
|
||||||
|
onClick={handle(onDelete)}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { format, parseISO, addHours, addDays } from "date-fns";
|
|||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -35,6 +35,7 @@ interface EventModalProps {
|
|||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultEndDate?: Date;
|
defaultEndDate?: Date;
|
||||||
|
defaultCalendarId?: string;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
@@ -113,6 +114,7 @@ export function EventModal({
|
|||||||
calendars,
|
calendars,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
defaultEndDate,
|
defaultEndDate,
|
||||||
|
defaultCalendarId,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -200,6 +202,7 @@ export function EventModal({
|
|||||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||||
|
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||||
const defaultCal = calendars.find(c => c.isDefault);
|
const defaultCal = calendars.find(c => c.isDefault);
|
||||||
return defaultCal?.id || calendars[0]?.id || "";
|
return defaultCal?.id || calendars[0]?.id || "";
|
||||||
});
|
});
|
||||||
@@ -233,6 +236,7 @@ export function EventModal({
|
|||||||
.map(p => ({ name: p.name, email: p.email }));
|
.map(p => ({ name: p.name, email: p.email }));
|
||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||||
|
|
||||||
// Report live preview to parent for grid outline
|
// Report live preview to parent for grid outline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -264,6 +268,9 @@ export function EventModal({
|
|||||||
if (!trimmedTitle || isSaving) return;
|
if (!trimmedTitle || isSaving) return;
|
||||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||||
|
|
||||||
|
const pendingAttendee = participantInputRef.current?.flush() ?? null;
|
||||||
|
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
|
||||||
|
|
||||||
const startStr = allDay
|
const startStr = allDay
|
||||||
? `${startDate}T00:00:00`
|
? `${startDate}T00:00:00`
|
||||||
: `${startDate}T${startTime}:00`;
|
: `${startDate}T${startTime}:00`;
|
||||||
@@ -301,6 +308,10 @@ export function EventModal({
|
|||||||
privacy: "public",
|
privacy: "public",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
data.uid = generateUUID();
|
||||||
|
}
|
||||||
|
|
||||||
if (location.trim()) {
|
if (location.trim()) {
|
||||||
data.locations = {
|
data.locations = {
|
||||||
loc1: {
|
loc1: {
|
||||||
@@ -373,18 +384,20 @@ export function EventModal({
|
|||||||
data.alerts = null;
|
data.alerts = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attendees.length > 0 && currentUserEmails.length > 0) {
|
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
|
||||||
const organizerEmail = currentUserEmails[0];
|
const organizerEmail = currentUserEmails[0];
|
||||||
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
||||||
data.participants = buildParticipantMap(
|
data.participants = buildParticipantMap(
|
||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
attendees
|
effectiveAttendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) as Record<string, CalendarParticipant>;
|
||||||
} else if (attendees.length === 0 && event?.participants) {
|
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||||
|
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
|
data.replyTo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave(data, shouldSendScheduling);
|
await onSave(data, shouldSendScheduling);
|
||||||
@@ -837,6 +850,7 @@ export function EventModal({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<ParticipantInput
|
<ParticipantInput
|
||||||
|
ref={participantInputRef}
|
||||||
participants={attendees}
|
participants={attendees}
|
||||||
onAdd={handleAddAttendee}
|
onAdd={handleAddAttendee}
|
||||||
onRemove={handleRemoveAttendee}
|
onRemove={handleRemoveAttendee}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
|||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface ICalImportModalProps {
|
interface ICalImportModalProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -126,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
setIsParsing(true);
|
setIsParsing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/fetch-ical", {
|
const response = await apiFetch("/api/fetch-ical", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: trimmed }),
|
body: JSON.stringify({ url: trimmed }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParticipantInputHandle {
|
||||||
|
flush: () => Participant | null;
|
||||||
|
}
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
|
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
|
||||||
const t = useTranslations("calendar.participants");
|
const t = useTranslations("calendar.participants");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
||||||
@@ -86,8 +90,26 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||||
|
addParticipant({ name: "", email: trimmed });
|
||||||
|
}
|
||||||
setTimeout(() => setShowSuggestions(false), 200);
|
setTimeout(() => setShowSuggestions(false), 200);
|
||||||
}, []);
|
}, [query, addParticipant]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
flush: () => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
|
||||||
|
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
|
||||||
|
const p = { name: "", email: trimmed };
|
||||||
|
onAdd(p);
|
||||||
|
setQuery("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
}), [query, participants, onAdd]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -143,7 +165,22 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
key={`${p.email}-${i}`}
|
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]"
|
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>
|
{!disabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onRemove(p.email);
|
||||||
|
setQuery(p.email);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
|
||||||
|
aria-label={`${t("edit")} ${p.name || p.email}`}
|
||||||
|
>
|
||||||
|
{p.name || p.email}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{p.name || p.email}</span>
|
||||||
|
)}
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -160,4 +197,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
|
|||||||
it('calls onDelete when delete button is clicked', () => {
|
it('calls onDelete when delete button is clicked', () => {
|
||||||
const onDelete = vi.fn();
|
const onDelete = vi.fn();
|
||||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
||||||
const deleteButton = screen.getAllByRole('button').find(
|
fireEvent.click(screen.getByLabelText('detail.more_actions'));
|
||||||
btn => btn.className.includes('text-red')
|
fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
|
||||||
);
|
|
||||||
fireEvent.click(deleteButton!);
|
|
||||||
expect(onDelete).toHaveBeenCalledOnce();
|
expect(onDelete).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const defaultProps = {
|
|||||||
onSearchChange: vi.fn(),
|
onSearchChange: vi.fn(),
|
||||||
onSelectContact: vi.fn(),
|
onSelectContact: vi.fn(),
|
||||||
onCreateNew: vi.fn(),
|
onCreateNew: vi.fn(),
|
||||||
categoryLabel: 'All Contacts',
|
|
||||||
selectedContactIds: new Set<string>(),
|
selectedContactIds: new Set<string>(),
|
||||||
onToggleSelection: vi.fn(),
|
onToggleSelection: vi.fn(),
|
||||||
onSelectRangeContacts: vi.fn(),
|
onSelectRangeContacts: vi.fn(),
|
||||||
@@ -45,6 +44,9 @@ const defaultProps = {
|
|||||||
onBulkDelete: vi.fn(),
|
onBulkDelete: vi.fn(),
|
||||||
onBulkAddToGroup: vi.fn(),
|
onBulkAddToGroup: vi.fn(),
|
||||||
onBulkExport: vi.fn(),
|
onBulkExport: vi.fn(),
|
||||||
|
onEditContact: vi.fn(),
|
||||||
|
onDeleteContact: vi.fn(),
|
||||||
|
onAddContactToGroup: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ContactList', () => {
|
describe('ContactList', () => {
|
||||||
@@ -76,8 +78,4 @@ describe('ContactList', () => {
|
|||||||
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows category label with count', () => {
|
|
||||||
render(<ContactList {...defaultProps} />);
|
|
||||||
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { Section } from "./contact-detail";
|
||||||
|
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
const EMAIL_LIMIT = 5;
|
||||||
|
const EVENT_LIMIT = 5;
|
||||||
|
const EVENT_LOOKAHEAD_DAYS = 365;
|
||||||
|
|
||||||
|
interface ContactActivityProps {
|
||||||
|
contact: ContactCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContactEmails(contact: ContactCard): string[] {
|
||||||
|
if (!contact.emails) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const e of Object.values(contact.emails)) {
|
||||||
|
const addr = e.address?.trim().toLowerCase();
|
||||||
|
if (addr && !seen.has(addr)) {
|
||||||
|
seen.add(addr);
|
||||||
|
result.push(addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
|
||||||
|
const conditions: Record<string, unknown>[] = [];
|
||||||
|
for (const addr of addresses) {
|
||||||
|
conditions.push({ from: addr });
|
||||||
|
conditions.push({ to: addr });
|
||||||
|
}
|
||||||
|
if (conditions.length === 1) return conditions[0];
|
||||||
|
return { operator: "OR", conditions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
|
||||||
|
if (event.participants) {
|
||||||
|
for (const p of Object.values(event.participants)) {
|
||||||
|
const email = p.email?.trim().toLowerCase();
|
||||||
|
if (email && addresses.has(email)) return true;
|
||||||
|
if (p.sendTo) {
|
||||||
|
for (const target of Object.values(p.sendTo)) {
|
||||||
|
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
|
||||||
|
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.organizerCalendarAddress) {
|
||||||
|
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
|
||||||
|
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
|
||||||
|
if (addresses.has(org)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmailSender(email: Email): { name: string; address: string } {
|
||||||
|
const from = email.from?.[0];
|
||||||
|
return {
|
||||||
|
name: from?.name?.trim() || from?.email || "",
|
||||||
|
address: from?.email || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||||
|
const groups = new Map<string, CalendarEvent[]>();
|
||||||
|
for (const e of events) {
|
||||||
|
const d = new Date(e.start);
|
||||||
|
const key = isNaN(d.getTime()) ? e.start : d.toISOString().slice(0, 10);
|
||||||
|
const arr = groups.get(key) || [];
|
||||||
|
arr.push(e);
|
||||||
|
groups.set(key, arr);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactActivity({ contact }: ContactActivityProps) {
|
||||||
|
const t = useTranslations("contacts.activity");
|
||||||
|
const format = useFormatter();
|
||||||
|
const router = useRouter();
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const selectEmail = useEmailStore((s) => s.selectEmail);
|
||||||
|
const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId);
|
||||||
|
const setSelectedDate = useCalendarStore((s) => s.setSelectedDate);
|
||||||
|
|
||||||
|
const [emails, setEmails] = useState<Email[] | null>(null);
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
|
||||||
|
const [emailsLoading, setEmailsLoading] = useState(false);
|
||||||
|
const [eventsLoading, setEventsLoading] = useState(false);
|
||||||
|
const [emailsError, setEmailsError] = useState(false);
|
||||||
|
const [eventsError, setEventsError] = useState(false);
|
||||||
|
|
||||||
|
const addresses = getContactEmails(contact);
|
||||||
|
const addressKey = addresses.join(",");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || addresses.length === 0) {
|
||||||
|
setEmails([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setEmailsLoading(true);
|
||||||
|
setEmailsError(false);
|
||||||
|
client
|
||||||
|
.advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEmails(res.emails);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEmailsError(true);
|
||||||
|
setEmails([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setEmailsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || addresses.length === 0) {
|
||||||
|
setEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setEventsLoading(true);
|
||||||
|
setEventsError(false);
|
||||||
|
const now = new Date();
|
||||||
|
const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
const addrSet = new Set(addresses);
|
||||||
|
client
|
||||||
|
.queryAllCalendarEvents(
|
||||||
|
{ after: now.toISOString(), before: before.toISOString() },
|
||||||
|
[{ property: "start", isAscending: true }],
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
.then((all) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const matching = all
|
||||||
|
.filter((e) => eventInvolvesContact(e, addrSet))
|
||||||
|
.sort((a, b) => a.start.localeCompare(b.start))
|
||||||
|
.slice(0, EVENT_LIMIT);
|
||||||
|
setEvents(matching);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEventsError(true);
|
||||||
|
setEvents([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setEventsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (addresses.length === 0) return null;
|
||||||
|
|
||||||
|
const handleOpenEmail = (email: Email) => {
|
||||||
|
selectEmail(email);
|
||||||
|
router.push("/mail");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEvent = (event: CalendarEvent) => {
|
||||||
|
const start = new Date(event.start);
|
||||||
|
if (!isNaN(start.getTime())) setSelectedDate(start);
|
||||||
|
setSelectedEventId(event.id);
|
||||||
|
router.push("/calendar");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEmailDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
|
const now = new Date();
|
||||||
|
const sameYear = d.getFullYear() === now.getFullYear();
|
||||||
|
return format.dateTime(d, sameYear
|
||||||
|
? { month: "short", day: "numeric" }
|
||||||
|
: { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEventTime = (event: CalendarEvent) => {
|
||||||
|
if (event.showWithoutTime) return t("all_day");
|
||||||
|
const d = new Date(event.start);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEventDateHeader = (isoDate: string) => {
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
if (isNaN(d.getTime())) return isoDate;
|
||||||
|
const now = new Date();
|
||||||
|
const sameYear = d.getFullYear() === now.getFullYear();
|
||||||
|
return format.dateTime(d, sameYear
|
||||||
|
? { weekday: "long", month: "long", day: "numeric" }
|
||||||
|
: { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventGroups = events ? Array.from(groupEventsByDate(events).entries()) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
|
||||||
|
<Section title={t("recent_emails")}>
|
||||||
|
{emailsLoading ? (
|
||||||
|
<LoadingRow />
|
||||||
|
) : emailsError ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||||
|
) : !emails || emails.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("no_emails")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="-mx-2">
|
||||||
|
{emails.map((email) => {
|
||||||
|
const sender = getEmailSender(email);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={email.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenEmail(email)}
|
||||||
|
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||||
|
>
|
||||||
|
<Avatar name={sender.name} email={sender.address} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{sender.name || t("unknown_sender")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||||
|
{formatEmailDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm truncate">
|
||||||
|
{email.subject || t("no_subject")}
|
||||||
|
</div>
|
||||||
|
{email.preview && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{email.preview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("upcoming_events")}>
|
||||||
|
{eventsLoading ? (
|
||||||
|
<LoadingRow />
|
||||||
|
) : eventsError ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||||
|
) : !events || events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("no_events")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{eventGroups.map(([dateKey, group]) => (
|
||||||
|
<div key={dateKey}>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
|
{formatEventDateHeader(dateKey)}
|
||||||
|
</div>
|
||||||
|
<div className="-mx-2">
|
||||||
|
{group.map((event) => (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenEvent(event)}
|
||||||
|
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
||||||
|
{formatEventTime(event)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm truncate flex-1 min-w-0">
|
||||||
|
{event.title || t("no_title")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingRow() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuHeader,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
ClipboardCopy,
|
||||||
|
Download,
|
||||||
|
Users,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Printer,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ContactCard } from "@/lib/jmap/types";
|
||||||
|
import { getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
|
import { exportContact } from "./contact-export";
|
||||||
|
import { printContact } from "./contact-print";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
function getContactPrimaryPhone(contact: ContactCard): string {
|
||||||
|
if (!contact.phones) return "";
|
||||||
|
return Object.values(contact.phones)[0]?.number || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactContextMenuProps {
|
||||||
|
contact: ContactCard;
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
isMultiSelect?: boolean;
|
||||||
|
selectedCount?: number;
|
||||||
|
onOpen: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onAddToGroup: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onBatchExport?: () => void;
|
||||||
|
onBatchAddToGroup?: () => void;
|
||||||
|
onBatchDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactContextMenu({
|
||||||
|
contact,
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
isMultiSelect = false,
|
||||||
|
selectedCount = 1,
|
||||||
|
onOpen,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onAddToGroup,
|
||||||
|
onDuplicate,
|
||||||
|
onBatchExport,
|
||||||
|
onBatchAddToGroup,
|
||||||
|
onBatchDelete,
|
||||||
|
}: ContactContextMenuProps) {
|
||||||
|
const t = useTranslations("contacts");
|
||||||
|
const email = getContactPrimaryEmail(contact);
|
||||||
|
const phone = getContactPrimaryPhone(contact);
|
||||||
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = () => {
|
||||||
|
if (!email) return;
|
||||||
|
window.location.href = `mailto:${email}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCall = () => {
|
||||||
|
if (!phone) return;
|
||||||
|
window.location.href = `tel:${phone}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success(t("detail.copied"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("detail.copy_failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
exportContact(contact);
|
||||||
|
toast.success(t("export.success", { count: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
printContact(contact);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showBatchActions) {
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuHeader>
|
||||||
|
{t("bulk.selected", { count: selectedCount })}
|
||||||
|
</ContextMenuHeader>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={t("bulk.add_to_group")}
|
||||||
|
onClick={handle(() => onBatchAddToGroup?.())}
|
||||||
|
disabled={!onBatchAddToGroup}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Download}
|
||||||
|
label={t("bulk.export")}
|
||||||
|
onClick={handle(() => onBatchExport?.())}
|
||||||
|
disabled={!onBatchExport}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("bulk.delete")}
|
||||||
|
onClick={handle(() => onBatchDelete?.())}
|
||||||
|
disabled={!onBatchDelete}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
|
||||||
|
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
|
||||||
|
{(email || phone) && <ContextMenuSeparator />}
|
||||||
|
{email && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Mail}
|
||||||
|
label={t("context_menu.send_email")}
|
||||||
|
onClick={handle(handleSendEmail)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{phone && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Phone}
|
||||||
|
label={t("context_menu.call")}
|
||||||
|
onClick={handle(handleCall)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{email && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={ClipboardCopy}
|
||||||
|
label={t("detail.copy_email")}
|
||||||
|
onClick={handle(() => handleCopy(email))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{phone && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={ClipboardCopy}
|
||||||
|
label={t("detail.copy_phone")}
|
||||||
|
onClick={handle(() => handleCopy(phone))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={t("context_menu.add_to_group")}
|
||||||
|
onClick={handle(onAddToGroup)}
|
||||||
|
/>
|
||||||
|
{onDuplicate && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Copy}
|
||||||
|
label={t("context_menu.duplicate")}
|
||||||
|
onClick={handle(onDuplicate)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Download}
|
||||||
|
label={t("context_menu.export_vcard")}
|
||||||
|
onClick={handle(handleExport)}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Printer}
|
||||||
|
label={t("context_menu.print")}
|
||||||
|
onClick={handle(handlePrint)}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("context_menu.delete")}
|
||||||
|
onClick={handle(onDelete)}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
|
import { ContactActivity } from "./contact-activity";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||||
import type { CertificateInfo } from "@/lib/smime/types";
|
import type { CertificateInfo } from "@/lib/smime/types";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { exportContact } from "./contact-export";
|
||||||
|
import { printContact } from "./contact-print";
|
||||||
|
|
||||||
|
type MoreItem =
|
||||||
|
| {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
separator?: false;
|
||||||
|
}
|
||||||
|
| { separator: true };
|
||||||
|
|
||||||
interface ContactDetailProps {
|
interface ContactDetailProps {
|
||||||
contact: ContactCard | null;
|
contact: ContactCard | null;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onAddToGroup?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -26,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
|
|||||||
return Object.keys(features).filter(k => features[k]).join(", ");
|
return Object.keys(features).filter(k => features[k]).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
|
||||||
|
if (typeof dateInput === "object" && dateInput !== null) {
|
||||||
|
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
|
||||||
|
const d = new Date(dateInput.utc);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const pd = dateInput as PartialDate;
|
||||||
|
return { year: pd.year, month: pd.month, day: pd.day };
|
||||||
|
}
|
||||||
|
const s = String(dateInput);
|
||||||
|
if (s.startsWith("--")) {
|
||||||
|
const parts = s.substring(2).split("-");
|
||||||
|
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
|
||||||
|
}
|
||||||
|
const d = new Date(s);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||||
|
const { year, month, day } = getDateParts(dateInput);
|
||||||
|
if (!year) return null;
|
||||||
|
const now = new Date();
|
||||||
|
let years = now.getFullYear() - year;
|
||||||
|
const m = month ?? 1;
|
||||||
|
const d = day ?? 1;
|
||||||
|
const nowM = now.getMonth() + 1;
|
||||||
|
const nowD = now.getDate();
|
||||||
|
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
|
||||||
|
if (years < 0) return null;
|
||||||
|
return years;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateInput: AnniversaryDate): string {
|
function formatDate(dateInput: AnniversaryDate): string {
|
||||||
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
|
||||||
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
|
||||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||||
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||||
try {
|
try {
|
||||||
@@ -40,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
|
|||||||
return String(dateInput.utc);
|
return String(dateInput.utc);
|
||||||
}
|
}
|
||||||
const pd = dateInput as PartialDate;
|
const pd = dateInput as PartialDate;
|
||||||
const year = pd.year;
|
|
||||||
const month = pd.month;
|
|
||||||
const day = pd.day;
|
|
||||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
|
||||||
if (day) parts.push(String(day));
|
if (pd.day) parts.push(String(pd.day));
|
||||||
if (year) parts.push(String(year));
|
if (pd.year) parts.push(String(pd.year));
|
||||||
return parts.join(' ') || String(dateInput);
|
return parts.join(' ') || String(dateInput);
|
||||||
}
|
}
|
||||||
const dateStr = String(dateInput);
|
const dateStr = String(dateInput);
|
||||||
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
|
|
||||||
if (dateStr.startsWith("--")) {
|
if (dateStr.startsWith("--")) {
|
||||||
// Partial date without year
|
|
||||||
const parts = dateStr.substring(2).split("-");
|
const parts = dateStr.substring(2).split("-");
|
||||||
const month = parseInt(parts[0], 10);
|
const month = parseInt(parts[0], 10);
|
||||||
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
|
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
|
||||||
@@ -69,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
|
|||||||
return dateStr;
|
return dateStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
|
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const smimeStore = useSmimeStore();
|
const smimeStore = useSmimeStore();
|
||||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||||
@@ -87,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
try {
|
try {
|
||||||
let derBytes: ArrayBuffer | string | null = null;
|
let derBytes: ArrayBuffer | string | null = null;
|
||||||
if (key.uri.startsWith('data:')) {
|
if (key.uri.startsWith('data:')) {
|
||||||
// data URI — extract base64 content
|
|
||||||
const commaIdx = key.uri.indexOf(',');
|
const commaIdx = key.uri.indexOf(',');
|
||||||
if (commaIdx === -1) continue;
|
if (commaIdx === -1) continue;
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
const b64 = key.uri.substring(commaIdx + 1);
|
||||||
@@ -96,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||||
derBytes = bytes.buffer;
|
derBytes = bytes.buffer;
|
||||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||||
// PEM-encoded certificate inline
|
|
||||||
derBytes = key.uri;
|
derBytes = key.uri;
|
||||||
}
|
}
|
||||||
if (!derBytes) continue;
|
if (!derBytes) continue;
|
||||||
@@ -127,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
|
|
||||||
const name = getContactDisplayName(contact);
|
const name = getContactDisplayName(contact);
|
||||||
const email = getContactPrimaryEmail(contact);
|
const email = getContactPrimaryEmail(contact);
|
||||||
|
const photoUri = getContactPhotoUri(contact);
|
||||||
|
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
exportContact(contact);
|
||||||
|
toast.success(t("export.success", { count: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
printContact(contact, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moreItems: MoreItem[] = [];
|
||||||
|
if (onAddToGroup) {
|
||||||
|
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
|
||||||
|
}
|
||||||
|
if (onDuplicate) {
|
||||||
|
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
|
||||||
|
}
|
||||||
|
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
|
||||||
|
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
|
||||||
|
moreItems.push({ separator: true });
|
||||||
|
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
|
||||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||||
@@ -170,255 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
|
|
||||||
const hasNickname = nicknames.length > 0;
|
const hasNickname = nicknames.length > 0;
|
||||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
||||||
|
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
|
||||||
|
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||||
|
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||||
|
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||||
|
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
<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={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
|
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||||
{hasNickname && (
|
{hasNickname && (
|
||||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||||
)}
|
)}
|
||||||
{titleLine && (
|
{subtitleParts.length > 0 && (
|
||||||
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
|
<p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
|
||||||
)}
|
|
||||||
{orgs.length > 0 && orgs[0].name && (
|
|
||||||
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||||
|
{email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${email}`}
|
||||||
|
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-1" />
|
||||||
|
{t("detail.compose_email")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${phone}`}
|
||||||
|
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4 mr-1" />
|
||||||
|
{t("context_menu.call")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
{t("form.edit_title")}
|
{t("form.edit_title")}
|
||||||
</Button>
|
</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 touch-manipulation">
|
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-6">
|
<div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
{hasContactDetails && (
|
||||||
{/* Contact info */}
|
<Section title={t("detail.section_contact")}>
|
||||||
{emails.length > 0 && (
|
<div className="space-y-3">
|
||||||
<Section icon={Mail} title={t("detail.emails")} category="contact">
|
|
||||||
{emails.map((e, i) => (
|
{emails.map((e, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 group">
|
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
|
||||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
|
<div className="flex items-center gap-2 group">
|
||||||
{e.address}
|
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
|
||||||
</a>
|
{e.address}
|
||||||
{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>
|
</a>
|
||||||
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
<RowActions>
|
||||||
|
<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")} />
|
||||||
|
</RowActions>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FieldRow>
|
||||||
))}
|
))}
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phones.length > 0 && (
|
|
||||||
<Section icon={Phone} title={t("detail.phones")} category="contact">
|
|
||||||
{phones.map((p, i) => {
|
{phones.map((p, i) => {
|
||||||
const featureStr = formatPhoneFeatures(p.features);
|
const features = formatPhoneFeatures(p.features);
|
||||||
|
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2 group">
|
<FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
|
||||||
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
<div className="flex items-center gap-2 group">
|
||||||
{p.number}
|
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||||
</a>
|
{p.number}
|
||||||
{p.contexts && <ContextBadge contexts={p.contexts} />}
|
</a>
|
||||||
{featureStr && (
|
<RowActions>
|
||||||
<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")} />
|
||||||
)}
|
</RowActions>
|
||||||
<CopyButton
|
</div>
|
||||||
value={p.number}
|
</FieldRow>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(roles.length > 0 || jobTitles.length > 1) && (
|
{addresses.map((a, i) => {
|
||||||
<Section icon={Briefcase} title={t("detail.titles")} category="work">
|
const lines: string[] = [];
|
||||||
{titles.map((tl, i) => (
|
if (a.full || a.fullAddress) {
|
||||||
<div key={i} className="text-sm flex items-center gap-2">
|
lines.push((a.full || a.fullAddress) as string);
|
||||||
<span>{tl.name}</span>
|
} else if (a.components && a.components.length > 0) {
|
||||||
{tl.kind && (
|
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
|
if (joined) lines.push(joined);
|
||||||
)}
|
} else {
|
||||||
</div>
|
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
|
||||||
))}
|
.map(s => (typeof s === "string" ? s.trim() : ""))
|
||||||
</Section>
|
.filter(Boolean) as string[];
|
||||||
)}
|
lines.push(...parts);
|
||||||
|
}
|
||||||
{orgs.length > 0 && (
|
return (
|
||||||
<Section icon={Building} title={t("detail.organizations")} category="work">
|
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
|
||||||
{orgs.map((o, i) => (
|
<div className="text-sm space-y-0.5">
|
||||||
<div key={i} className="text-sm">
|
{lines.map((line, idx) => (
|
||||||
{o.name}
|
<div key={idx}>{line}</div>
|
||||||
{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.full || a.fullAddress
|
|
||||||
? (a.full || a.fullAddress)
|
|
||||||
: a.components && a.components.length > 0
|
|
||||||
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
|
||||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
|
||||||
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
|
||||||
</div>
|
|
||||||
{a.timeZone && (
|
{a.timeZone && (
|
||||||
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
|
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</FieldRow>
|
||||||
</div>
|
);
|
||||||
</Section>
|
})}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onlineServices.length > 0 && (
|
|
||||||
<Section icon={Globe} title={t("detail.online_services")} category="digital">
|
|
||||||
{onlineServices.map((svc, i) => (
|
{onlineServices.map((svc, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 group">
|
<FieldRow
|
||||||
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
|
key={`os${i}`}
|
||||||
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
icon={Globe}
|
||||||
{svc.user || svc.uri}
|
label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
|
||||||
</a>
|
>
|
||||||
) : (
|
<div className="flex items-center gap-2 group">
|
||||||
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
|
{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.service && (
|
{svc.user || svc.uri}
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
|
</a>
|
||||||
)}
|
) : (
|
||||||
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
|
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
|
||||||
<CopyButton
|
)}
|
||||||
value={svc.user || svc.uri}
|
<RowActions>
|
||||||
label={t("detail.copy_url")}
|
<CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||||
successMsg={t("detail.copied")}
|
</RowActions>
|
||||||
failMsg={t("detail.copy_failed")}
|
</div>
|
||||||
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
</FieldRow>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{anniversaries.length > 0 && (
|
{hasWork && (
|
||||||
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
|
<Section title={t("detail.section_work")}>
|
||||||
{anniversaries.map((ann, i) => (
|
<div className="space-y-3">
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
{orgs.map((o, i) => (
|
||||||
<span>{formatDate(ann.date)}</span>
|
<FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
<div className="text-sm">
|
||||||
{t(`detail.anniversary_${ann.kind}`)}
|
{o.name}
|
||||||
</span>
|
{o.units && o.units.length > 0 && (
|
||||||
</div>
|
<span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FieldRow>
|
||||||
))}
|
))}
|
||||||
</Section>
|
{titles.map((tl, i) => (
|
||||||
)}
|
<FieldRow
|
||||||
|
key={`tl${i}`}
|
||||||
{personalInfo.length > 0 && (
|
icon={Briefcase}
|
||||||
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
|
label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
|
||||||
{personalInfo.map((pi, i) => (
|
>
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
<div className="text-sm">{tl.name}</div>
|
||||||
<span>{pi.value}</span>
|
</FieldRow>
|
||||||
<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>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
|
{hasPersonal && (
|
||||||
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
|
<Section title={t("detail.section_personal")}>
|
||||||
<div className="text-sm">
|
<div className="space-y-3">
|
||||||
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
|
{anniversaries.map((ann, i) => {
|
||||||
{contact.speakToAs.pronouns && (() => {
|
const years = getCompletedYears(ann.date);
|
||||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
|
||||||
return firstPronoun ? (
|
return (
|
||||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
|
||||||
) : null;
|
<div className="text-sm">
|
||||||
})()}
|
{formatDate(ann.date)}
|
||||||
</div>
|
{years !== null && (
|
||||||
</Section>
|
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{preferredLanguages.length > 0 && (
|
</FieldRow>
|
||||||
<Section icon={Languages} title={t("detail.languages")} category="personal">
|
);
|
||||||
|
})}
|
||||||
|
{hasGender && (
|
||||||
|
<FieldRow icon={UserCircle} label={t("detail.gender")}>
|
||||||
|
<div className="text-sm">
|
||||||
|
{contact.speakToAs?.grammaticalGender && (
|
||||||
|
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
|
||||||
|
)}
|
||||||
|
{contact.speakToAs?.pronouns && (() => {
|
||||||
|
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||||
|
return firstPronoun ? (
|
||||||
|
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</FieldRow>
|
||||||
|
)}
|
||||||
{preferredLanguages.map((lang, i) => (
|
{preferredLanguages.map((lang, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
<FieldRow
|
||||||
<span>{lang.language}</span>
|
key={`lg${i}`}
|
||||||
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
|
icon={Languages}
|
||||||
</div>
|
label={formatContexts(lang.contexts) || t("detail.language_label")}
|
||||||
|
>
|
||||||
|
<div className="text-sm">{lang.language}</div>
|
||||||
|
</FieldRow>
|
||||||
))}
|
))}
|
||||||
</Section>
|
{personalInfo.map((pi, i) => (
|
||||||
)}
|
<FieldRow
|
||||||
|
key={`pi${i}`}
|
||||||
|
icon={Heart}
|
||||||
|
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm">{pi.value}</div>
|
||||||
|
</FieldRow>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{keywords.length > 0 && (
|
{keywords.length > 0 && (
|
||||||
<Section icon={Tag} title={t("detail.categories")} category="digital">
|
<Section title={t("detail.categories")}>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{keywords.map((kw, i) => (
|
{keywords.map((kw, i) => (
|
||||||
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||||
{kw}
|
{kw}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{relatedTo.length > 0 && (
|
{relatedTo.length > 0 && (
|
||||||
<Section icon={Users} title={t("detail.related_contacts")} category="personal">
|
<Section title={t("detail.related_contacts")}>
|
||||||
|
<div className="space-y-2">
|
||||||
{relatedTo.map(([uri, rel], i) => {
|
{relatedTo.map(([uri, rel], i) => {
|
||||||
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
|
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
<FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
|
||||||
<span>{uri}</span>
|
<div className="text-sm break-all">{uri}</div>
|
||||||
{relType && (
|
</FieldRow>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{cryptoKeys.length > 0 && (
|
{cryptoKeys.length > 0 && (
|
||||||
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
|
<Section title={t("detail.crypto_keys")}>
|
||||||
|
<div className="space-y-3">
|
||||||
{cryptoKeys.map((key, i) => {
|
{cryptoKeys.map((key, i) => {
|
||||||
const certInfo = parsedCerts.get(i);
|
const certInfo = parsedCerts.get(i);
|
||||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||||
@@ -427,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
|
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||||
{certInfo ? (
|
{certInfo ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -448,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
||||||
</div>
|
</div>
|
||||||
{!alreadyImported && (
|
{!alreadyImported && (
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="ml-4 mt-1"
|
|
||||||
onClick={() => handleImportContactCert(i)}
|
|
||||||
>
|
|
||||||
<Download className="w-3 h-3 mr-1" />
|
<Download className="w-3 h-3 mr-1" />
|
||||||
{t("detail.import_to_smime")}
|
{t("detail.import_to_smime")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -463,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm break-all">
|
<div className="flex items-start gap-2 text-sm break-all">
|
||||||
|
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
{key.uri}
|
{key.uri}
|
||||||
@@ -476,91 +543,168 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
||||||
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
|
<Section title={t("detail.calendar")}>
|
||||||
|
<div className="space-y-3">
|
||||||
{contact.calendarUri && (
|
{contact.calendarUri && (
|
||||||
<div className="text-sm">
|
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
|
||||||
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
|
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||||
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
|
{contact.calendarUri}
|
||||||
</div>
|
</a>
|
||||||
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
{contact.schedulingUri && (
|
{contact.schedulingUri && (
|
||||||
<div className="text-sm">
|
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
|
||||||
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
|
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||||
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
|
{contact.schedulingUri}
|
||||||
</div>
|
</a>
|
||||||
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
{contact.freeBusyUri && (
|
{contact.freeBusyUri && (
|
||||||
<div className="text-sm">
|
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
|
||||||
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
|
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||||
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
|
{contact.freeBusyUri}
|
||||||
</div>
|
</a>
|
||||||
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes span full width */}
|
{notes.length > 0 && (
|
||||||
{notes.length > 0 && (
|
<Section title={t("detail.notes")}>
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="flex items-start gap-3">
|
||||||
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
|
<StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
|
||||||
|
<div className="text-sm space-y-2 flex-1 min-w-0">
|
||||||
{notes.map((n, i) => (
|
{notes.map((n, i) => (
|
||||||
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
|
<p key={i} className="whitespace-pre-wrap">{n.note}</p>
|
||||||
))}
|
))}
|
||||||
</Section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Timestamps span full width */}
|
<ContactActivity contact={contact} />
|
||||||
{(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 || contact.updated) && (
|
||||||
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
<div className="py-4 text-xs text-muted-foreground space-y-1">
|
||||||
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
||||||
</div>
|
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContexts(contexts?: Record<string, boolean>): string {
|
||||||
|
if (!contexts) return "";
|
||||||
|
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<section className={cn("py-6", className)}>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowActions({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocMouseDown = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocMouseDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocMouseDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="touch-manipulation"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
||||||
|
>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
if (item.separator) {
|
||||||
|
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
|
||||||
|
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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.5 pl-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
|
|
||||||
const labels = Object.keys(contexts).filter(k => contexts[k]);
|
|
||||||
if (labels.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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 }) {
|
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -572,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
|
|||||||
toast.error(failMsg);
|
toast.error(failMsg);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
|
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
|
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||||
|
|
||||||
interface EmailEntry {
|
interface EmailEntry {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -49,59 +50,78 @@ interface ContactFormProps {
|
|||||||
contact?: ContactCard | null;
|
contact?: ContactCard | null;
|
||||||
addressBooks?: AddressBook[];
|
addressBooks?: AddressBook[];
|
||||||
allKeywords?: string[];
|
allKeywords?: string[];
|
||||||
|
defaultAddressBookId?: string;
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
|
||||||
|
|
||||||
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 }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
collapsible?: boolean;
|
collapsible?: boolean;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
category?: FormCategory;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen || !collapsible);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
|
<section className="py-5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
|
"flex items-center gap-2 w-full text-left",
|
||||||
collapsible && "hover:text-muted-foreground cursor-pointer",
|
collapsible ? "cursor-pointer" : "cursor-default"
|
||||||
!collapsible && "cursor-default"
|
|
||||||
)}
|
)}
|
||||||
onClick={() => collapsible && setOpen(!open)}
|
onClick={() => collapsible && setOpen(!open)}
|
||||||
tabIndex={collapsible ? 0 : -1}
|
tabIndex={collapsible ? 0 : -1}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
<span className="flex-1 text-left">{title}</span>
|
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||||
{collapsible && (
|
{collapsible && (
|
||||||
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{(open || !collapsible) && (
|
||||||
<div className="space-y-3 pt-3 pb-1">
|
<div className="space-y-3 mt-3">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_PHOTO_DIM = 512;
|
||||||
|
const PHOTO_QUALITY = 0.85;
|
||||||
|
|
||||||
|
async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height));
|
||||||
|
const w = Math.max(1, Math.round(img.width * ratio));
|
||||||
|
const h = Math.max(1, Math.round(img.height * ratio));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error("canvas-unsupported"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY);
|
||||||
|
resolve({ uri, mediaType: "image/jpeg" });
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error("invalid-image"));
|
||||||
|
img.src = reader.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error("read-failed"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function Select({ value, onChange, children, className }: {
|
function Select({ value, onChange, children, className }: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||||
@@ -124,11 +144,13 @@ function Select({ value, onChange, children, className }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
|
||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
||||||
|
const findComponent = (...kinds: string[]) =>
|
||||||
|
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
|
||||||
|
|
||||||
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||||
function anniversaryDateToString(date: AnniversaryDate): string {
|
function anniversaryDateToString(date: AnniversaryDate): string {
|
||||||
@@ -191,11 +213,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
|
||||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
|
||||||
const [surname, setSurname] = useState(findComponent("surname"));
|
const [surname, setSurname] = useState(findComponent("surname"));
|
||||||
const [suffix, setSuffix] = useState(findComponent("suffix"));
|
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
|
||||||
|
|
||||||
const [nickname, setNickname] = useState(
|
const [nickname, setNickname] = useState(
|
||||||
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
||||||
@@ -309,14 +331,61 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
return ids[0];
|
return ids[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
|
||||||
|
return defaultAddressBookId;
|
||||||
|
}
|
||||||
return "";
|
return "";
|
||||||
}, [contact]);
|
}, [contact, defaultAddressBookId, addressBooks]);
|
||||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||||
|
|
||||||
|
const initialPhotoEntry = useMemo(() => {
|
||||||
|
if (!contact?.media) return null;
|
||||||
|
for (const [key, m] of Object.entries(contact.media)) {
|
||||||
|
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [contact]);
|
||||||
|
const [photoUri, setPhotoUri] = useState<string | undefined>(initialPhotoEntry?.uri);
|
||||||
|
const [photoMediaType, setPhotoMediaType] = useState<string | undefined>(initialPhotoEntry?.mediaType);
|
||||||
|
const [photoError, setPhotoError] = useState<string | null>(null);
|
||||||
|
const [photoUploading, setPhotoUploading] = useState(false);
|
||||||
|
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
setPhotoError(t("photo_invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
setPhotoError(t("photo_too_large"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhotoError(null);
|
||||||
|
setPhotoUploading(true);
|
||||||
|
try {
|
||||||
|
const { uri, mediaType } = await processImageFile(file);
|
||||||
|
setPhotoUri(uri);
|
||||||
|
setPhotoMediaType(mediaType);
|
||||||
|
} catch {
|
||||||
|
setPhotoError(t("photo_invalid"));
|
||||||
|
} finally {
|
||||||
|
setPhotoUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoRemove = () => {
|
||||||
|
setPhotoUri(undefined);
|
||||||
|
setPhotoMediaType(undefined);
|
||||||
|
setPhotoError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const validateEmail = (address: string): boolean => {
|
const validateEmail = (address: string): boolean => {
|
||||||
if (!address.trim()) return true;
|
if (!address.trim()) return true;
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
|
||||||
@@ -373,12 +442,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
phonesMap[`p${i}`] = obj;
|
phonesMap[`p${i}`] = obj;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||||
const nameComponents = [];
|
const nameComponents = [];
|
||||||
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
|
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.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 (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.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() });
|
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||||
|
|
||||||
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||||
@@ -427,6 +497,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaMap: Record<string, ContactMedia> = {};
|
||||||
|
if (contact?.media) {
|
||||||
|
for (const [key, m] of Object.entries(contact.media)) {
|
||||||
|
if (m.kind !== "photo") mediaMap[key] = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (photoUri) {
|
||||||
|
const photoKey = initialPhotoEntry?.key || "photo";
|
||||||
|
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
||||||
|
}
|
||||||
|
|
||||||
const data: Partial<ContactCard> = {
|
const data: Partial<ContactCard> = {
|
||||||
name: { components: nameComponents, isOrdered: true },
|
name: { components: nameComponents, isOrdered: true },
|
||||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||||
@@ -453,6 +534,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
calendarUri: calendarUri.trim() || undefined,
|
calendarUri: calendarUri.trim() || undefined,
|
||||||
schedulingUri: schedulingUri.trim() || undefined,
|
schedulingUri: schedulingUri.trim() || undefined,
|
||||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||||
|
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
|
||||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -466,6 +548,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
|
||||||
|
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
|
<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">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
@@ -478,38 +563,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4 max-w-3xl">
|
||||||
{error && (
|
{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">
|
<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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="flex items-center gap-4 pb-4">
|
||||||
|
<button
|
||||||
{/* Address Book Selector */}
|
type="button"
|
||||||
{addressBooks && addressBooks.length > 1 && (
|
onClick={() => photoInputRef.current?.click()}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
disabled={photoUploading}
|
||||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
|
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
|
||||||
<select
|
title={t("upload_photo")}
|
||||||
value={selectedBookId}
|
aria-label={t("upload_photo")}
|
||||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
>
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
<Avatar
|
||||||
|
name={previewName || undefined}
|
||||||
|
email={previewEmail || undefined}
|
||||||
|
contactPhotoUri={photoUri}
|
||||||
|
size="lg"
|
||||||
|
className="!w-20 !h-20 !text-xl"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<Camera className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={photoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handlePhotoSelect}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
|
||||||
|
{photoError && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
|
||||||
|
)}
|
||||||
|
{photoUri && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePhotoRemove}
|
||||||
|
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||||
>
|
>
|
||||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
<Trash2 className="w-3 h-3" />
|
||||||
{addressBooks.map((book) => (
|
{t("remove_photo")}
|
||||||
<option key={book.id} value={book.id}>
|
</button>
|
||||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
)}
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormSection>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
|
||||||
|
{addressBooks && addressBooks.length > 1 && (
|
||||||
|
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
|
||||||
|
<select
|
||||||
|
value={selectedBookId}
|
||||||
|
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
>
|
||||||
|
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||||
|
{addressBooks.map((book) => (
|
||||||
|
<option key={book.id} value={book.id}>
|
||||||
|
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name & Identity — full width */}
|
<FormSection icon={User} title={t("section_identity")}>
|
||||||
<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 className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||||
@@ -543,10 +672,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
|
<FormSection icon={Mail} title={t("email")}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{emails.map((entry, i) => (
|
{emails.map((entry, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
@@ -575,7 +703,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setEmails(next);
|
setEmails(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -598,7 +726,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
|
<FormSection icon={Phone} title={t("phone")}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{phones.map((entry, i) => (
|
{phones.map((entry, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -639,7 +767,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setPhones(next);
|
setPhones(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -656,7 +784,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Work & Organization */}
|
{/* Work & Organization */}
|
||||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
|
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||||
@@ -677,9 +805,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Addresses — full width */}
|
{/* Addresses */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
|
||||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{addresses.map((addr, i) => (
|
{addresses.map((addr, i) => (
|
||||||
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
||||||
@@ -698,7 +825,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
value={addr.context}
|
value={addr.context}
|
||||||
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -711,10 +838,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Online Services */}
|
{/* Online Services */}
|
||||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
|
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{onlineServices.map((svc, i) => (
|
{onlineServices.map((svc, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -743,7 +869,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Anniversaries */}
|
{/* Anniversaries */}
|
||||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
|
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{anniversaries.map((ann, i) => (
|
{anniversaries.map((ann, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -775,7 +901,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Personal Info */}
|
{/* Personal Info */}
|
||||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
|
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{personalInfoEntries.map((pi, i) => (
|
{personalInfoEntries.map((pi, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -816,7 +942,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
|
||||||
<CategoryComboBox
|
<CategoryComboBox
|
||||||
keywordsStr={keywordsStr}
|
keywordsStr={keywordsStr}
|
||||||
onChange={setKeywordsStr}
|
onChange={setKeywordsStr}
|
||||||
@@ -828,12 +954,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
|
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
<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">
|
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="masculine">{t("gender_male")}</option>
|
<option value="masculine">{t("gender_male")}</option>
|
||||||
<option value="feminine">{t("gender_female")}</option>
|
<option value="feminine">{t("gender_female")}</option>
|
||||||
<option value="other">{t("gender_other")}</option>
|
<option value="other">{t("gender_other")}</option>
|
||||||
@@ -849,7 +975,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
|
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
||||||
@@ -866,9 +992,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Notes — full width */}
|
{/* Notes */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
|
||||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
|
||||||
<textarea
|
<textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
@@ -876,7 +1001,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
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"
|
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>
|
</FormSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function ContactImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
|
<div className="text-sm font-medium truncate">{cName || cEmail || "-"}</div>
|
||||||
{cEmail && cName && (
|
{cEmail && cName && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ interface ContactListItemProps {
|
|||||||
selectedContactIds: Set<string>;
|
selectedContactIds: Set<string>;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) {
|
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
|
||||||
const name = getContactDisplayName(contact);
|
const name = getContactDisplayName(contact);
|
||||||
const email = getContactPrimaryEmail(contact);
|
const email = getContactPrimaryEmail(contact);
|
||||||
const org = contact.organizations
|
const org = contact.organizations
|
||||||
@@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||||
isSelected
|
isSelected
|
||||||
@@ -90,7 +92,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{name || email || "—"}
|
{name || email || "-"}
|
||||||
</div>
|
</div>
|
||||||
{density !== 'extra-compact' && email && name && (
|
{density !== 'extra-compact' && email && name && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||||
|
|||||||
@@ -1,16 +1,98 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react";
|
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContactListItem } from "./contact-list-item";
|
import { ContactListItem } from "./contact-list-item";
|
||||||
|
import { ContactContextMenu } from "./contact-context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
type TriState = boolean | null;
|
||||||
|
|
||||||
|
interface ListFilters {
|
||||||
|
organization: string;
|
||||||
|
jobTitle: string;
|
||||||
|
location: string;
|
||||||
|
emailDomain: string;
|
||||||
|
birthdayMonth: number | null;
|
||||||
|
hasEmail: TriState;
|
||||||
|
hasPhone: TriState;
|
||||||
|
hasPhoto: TriState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FILTERS: ListFilters = {
|
||||||
|
organization: "",
|
||||||
|
jobTitle: "",
|
||||||
|
location: "",
|
||||||
|
emailDomain: "",
|
||||||
|
birthdayMonth: null,
|
||||||
|
hasEmail: null,
|
||||||
|
hasPhone: null,
|
||||||
|
hasPhoto: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function cycleTri(v: TriState): TriState {
|
||||||
|
return v === null ? true : v === true ? false : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActiveFilters(f: ListFilters): number {
|
||||||
|
let n = 0;
|
||||||
|
if (f.organization.trim()) n++;
|
||||||
|
if (f.jobTitle.trim()) n++;
|
||||||
|
if (f.location.trim()) n++;
|
||||||
|
if (f.emailDomain.trim()) n++;
|
||||||
|
if (f.birthdayMonth !== null) n++;
|
||||||
|
if (f.hasEmail !== null) n++;
|
||||||
|
if (f.hasPhone !== null) n++;
|
||||||
|
if (f.hasPhoto !== null) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTri(actual: boolean, filter: TriState): boolean {
|
||||||
|
if (filter === null) return true;
|
||||||
|
return actual === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnniversaryMonth(date: AnniversaryDate): number | null {
|
||||||
|
if (typeof date === "string") {
|
||||||
|
const iso = date.match(/^(\d{4})-(\d{2})/);
|
||||||
|
if (iso) return parseInt(iso[2], 10);
|
||||||
|
const partial = date.match(/^--(\d{2})/);
|
||||||
|
if (partial) return parseInt(partial[1], 10);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("month" in date && date.month) return date.month;
|
||||||
|
if ("utc" in date && date.utc) {
|
||||||
|
const d = new Date(date.utc);
|
||||||
|
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
|
||||||
|
value === true && "bg-primary/10 border-primary/30 text-primary",
|
||||||
|
value === false && "bg-muted border-border text-muted-foreground line-through",
|
||||||
|
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ContactListProps {
|
interface ContactListProps {
|
||||||
contacts: ContactCard[];
|
contacts: ContactCard[];
|
||||||
selectedContactId: string | null;
|
selectedContactId: string | null;
|
||||||
@@ -18,7 +100,6 @@ interface ContactListProps {
|
|||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
onSelectContact: (id: string) => void;
|
onSelectContact: (id: string) => void;
|
||||||
onCreateNew: () => void;
|
onCreateNew: () => void;
|
||||||
categoryLabel: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedContactIds: Set<string>;
|
selectedContactIds: Set<string>;
|
||||||
onToggleSelection: (id: string) => void;
|
onToggleSelection: (id: string) => void;
|
||||||
@@ -28,6 +109,9 @@ interface ContactListProps {
|
|||||||
onBulkDelete: () => void;
|
onBulkDelete: () => void;
|
||||||
onBulkAddToGroup: () => void;
|
onBulkAddToGroup: () => void;
|
||||||
onBulkExport: () => void;
|
onBulkExport: () => void;
|
||||||
|
onEditContact: (id: string) => void;
|
||||||
|
onDeleteContact: (contact: ContactCard) => void;
|
||||||
|
onAddContactToGroup: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactList({
|
export function ContactList({
|
||||||
@@ -37,7 +121,6 @@ export function ContactList({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onCreateNew,
|
onCreateNew,
|
||||||
categoryLabel,
|
|
||||||
className,
|
className,
|
||||||
selectedContactIds,
|
selectedContactIds,
|
||||||
onToggleSelection,
|
onToggleSelection,
|
||||||
@@ -47,32 +130,99 @@ export function ContactList({
|
|||||||
onBulkDelete,
|
onBulkDelete,
|
||||||
onBulkAddToGroup,
|
onBulkAddToGroup,
|
||||||
onBulkExport,
|
onBulkExport,
|
||||||
|
onEditContact,
|
||||||
|
onDeleteContact,
|
||||||
|
onAddContactToGroup,
|
||||||
}: ContactListProps) {
|
}: ContactListProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const locale = useLocale();
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
||||||
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||||
|
const activeFilters = countActiveFilters(filters);
|
||||||
|
|
||||||
|
const monthNames = useMemo(() => {
|
||||||
|
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
|
||||||
|
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!searchQuery) return contacts;
|
const lower = searchQuery.trim().toLowerCase();
|
||||||
const lower = searchQuery.toLowerCase();
|
const orgLower = filters.organization.trim().toLowerCase();
|
||||||
|
const jobLower = filters.jobTitle.trim().toLowerCase();
|
||||||
|
const locLower = filters.location.trim().toLowerCase();
|
||||||
|
const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, "");
|
||||||
|
|
||||||
return contacts.filter((c) => {
|
return contacts.filter((c) => {
|
||||||
|
const emails = c.emails ? Object.values(c.emails) : [];
|
||||||
|
const phones = c.phones ? Object.values(c.phones) : [];
|
||||||
|
const orgs = c.organizations ? Object.values(c.organizations) : [];
|
||||||
|
const titles = c.titles ? Object.values(c.titles) : [];
|
||||||
|
const addresses = c.addresses ? Object.values(c.addresses) : [];
|
||||||
|
const anniversaries = c.anniversaries ? Object.values(c.anniversaries) : [];
|
||||||
|
|
||||||
|
if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
|
||||||
|
if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
|
||||||
|
if (!matchTri(!!getContactPhotoUri(c), filters.hasPhoto)) return false;
|
||||||
|
|
||||||
|
if (orgLower) {
|
||||||
|
const match = orgs.some((o) => {
|
||||||
|
if (o.name?.toLowerCase().includes(orgLower)) return true;
|
||||||
|
if (o.units?.some((u) => u.name?.toLowerCase().includes(orgLower))) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobLower) {
|
||||||
|
if (!titles.some((ti) => ti.name?.toLowerCase().includes(jobLower))) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locLower) {
|
||||||
|
const match = addresses.some((a) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (a.full) parts.push(a.full);
|
||||||
|
if (a.fullAddress) parts.push(a.fullAddress);
|
||||||
|
if (a.locality) parts.push(a.locality);
|
||||||
|
if (a.region) parts.push(a.region);
|
||||||
|
if (a.country) parts.push(a.country);
|
||||||
|
if (a.postcode) parts.push(a.postcode);
|
||||||
|
if (a.street) parts.push(a.street);
|
||||||
|
if (a.components) {
|
||||||
|
for (const comp of a.components) {
|
||||||
|
if (comp.value) parts.push(comp.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.some((p) => p.toLowerCase().includes(locLower));
|
||||||
|
});
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domainLower) {
|
||||||
|
const match = emails.some((e) => {
|
||||||
|
const at = e.address?.toLowerCase().split("@");
|
||||||
|
return at && at.length > 1 && at[1].includes(domainLower);
|
||||||
|
});
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.birthdayMonth !== null) {
|
||||||
|
const target = filters.birthdayMonth;
|
||||||
|
const match = anniversaries.some((a) => a.kind === "birth" && getAnniversaryMonth(a.date) === target);
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lower) return true;
|
||||||
const name = getContactDisplayName(c).toLowerCase();
|
const name = getContactDisplayName(c).toLowerCase();
|
||||||
const emails = c.emails
|
if (name.includes(lower)) return true;
|
||||||
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
|
||||||
: [];
|
if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
|
||||||
const phones = c.phones
|
if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
|
||||||
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "")
|
return false;
|
||||||
: [];
|
|
||||||
const org = c.organizations
|
|
||||||
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
|
|
||||||
: [];
|
|
||||||
return (
|
|
||||||
name.includes(lower) ||
|
|
||||||
emails.some((e) => e.includes(lower)) ||
|
|
||||||
phones.some((p) => p.includes(lower)) ||
|
|
||||||
org.some((o) => o.includes(lower))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, [contacts, searchQuery]);
|
}, [contacts, searchQuery, filters]);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
@@ -84,29 +234,214 @@ export function ContactList({
|
|||||||
|
|
||||||
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
|
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
|
||||||
|
|
||||||
|
// Group sorted contacts by first letter of display name. Non-letter
|
||||||
|
// starters (digits, symbols, empty) collect under "#" which sorts last.
|
||||||
|
const groupedSections = useMemo(() => {
|
||||||
|
const collator = new Intl.Collator(locale, { sensitivity: "base" });
|
||||||
|
const groups = new Map<string, ContactCard[]>();
|
||||||
|
for (const contact of sorted) {
|
||||||
|
const name = getContactDisplayName(contact).trim();
|
||||||
|
const first = name.charAt(0);
|
||||||
|
const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u)
|
||||||
|
? first.toLocaleUpperCase(locale)
|
||||||
|
: "#";
|
||||||
|
const bucket = groups.get(letter);
|
||||||
|
if (bucket) bucket.push(contact);
|
||||||
|
else groups.set(letter, [contact]);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
if (a === "#") return 1;
|
||||||
|
if (b === "#") return -1;
|
||||||
|
return collator.compare(a, b);
|
||||||
|
})
|
||||||
|
.map(([letter, items]) => ({ letter, items }));
|
||||||
|
}, [sorted, locale]);
|
||||||
|
|
||||||
const hasSelection = selectedContactIds.size > 0;
|
const hasSelection = selectedContactIds.size > 0;
|
||||||
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full", className)}>
|
<div className={cn("flex flex-col h-full", className)}>
|
||||||
{/* Search header */}
|
{/* Toolbar: select / search / filter */}
|
||||||
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="border-b border-border bg-background">
|
||||||
<div className="flex items-center justify-between">
|
<div className="px-3 py-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground truncate">
|
<div className="flex items-center gap-1.5">
|
||||||
{categoryLabel} ({contacts.length})
|
<button
|
||||||
</span>
|
type="button"
|
||||||
</div>
|
onClick={() => {
|
||||||
<div className="relative">
|
if (hasSelection) {
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
if (allSelected) onClearSelection();
|
||||||
<Input
|
else onSelectAll(sortedIds);
|
||||||
placeholder={t("search_placeholder")}
|
} else if (sortedIds.length > 0) {
|
||||||
value={searchQuery}
|
const target = selectedContactId && sortedIds.includes(selectedContactId)
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
? selectedContactId
|
||||||
className="pl-8 h-8 text-sm"
|
: sortedIds[0];
|
||||||
/>
|
onToggleSelection(target);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
hasSelection
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
|
||||||
|
>
|
||||||
|
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("search_placeholder")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
className="absolute right-2 top-1/2 -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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
filtersOpen || activeFilters > 0
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
title={t("filters.toggle")}
|
||||||
|
aria-label={t("filters.toggle")}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
{!filtersOpen && activeFilters > 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">
|
||||||
|
{activeFilters}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-foreground">{t("filters.title")}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3 mr-1" />
|
||||||
|
{t("filters.clear")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setFiltersOpen(false)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label={t("filters.close")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.organization")}</label>
|
||||||
|
<Input
|
||||||
|
value={filters.organization}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
|
||||||
|
placeholder={t("filters.organization_placeholder")}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
|
||||||
|
<Input
|
||||||
|
value={filters.jobTitle}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
|
||||||
|
placeholder={t("filters.job_title_placeholder")}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
|
||||||
|
<Input
|
||||||
|
value={filters.location}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
|
||||||
|
placeholder={t("filters.location_placeholder")}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
|
||||||
|
<Input
|
||||||
|
value={filters.emailDomain}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
|
||||||
|
placeholder={t("filters.email_domain_placeholder")}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
|
||||||
|
<select
|
||||||
|
value={filters.birthdayMonth ?? ""}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))}
|
||||||
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||||
|
aria-label={t("filters.birthday_month")}
|
||||||
|
>
|
||||||
|
<option value="">{t("filters.any_month")}</option>
|
||||||
|
{monthNames.map((name, i) => (
|
||||||
|
<option key={i} value={i + 1}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<ToggleChip
|
||||||
|
icon={<Mail className="w-3.5 h-3.5" />}
|
||||||
|
label={t("filters.has_email")}
|
||||||
|
value={filters.hasEmail}
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
|
||||||
|
/>
|
||||||
|
<ToggleChip
|
||||||
|
icon={<Phone className="w-3.5 h-3.5" />}
|
||||||
|
label={t("filters.has_phone")}
|
||||||
|
value={filters.hasPhone}
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
|
||||||
|
/>
|
||||||
|
<ToggleChip
|
||||||
|
icon={<ImageIcon className="w-3.5 h-3.5" />}
|
||||||
|
label={t("filters.has_photo")}
|
||||||
|
value={filters.hasPhoto}
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
||||||
@@ -157,19 +492,32 @@ export function ContactList({
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
||||||
{searchQuery ? (
|
{searchQuery || activeFilters > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
{activeFilters > 0 && !searchQuery ? (
|
||||||
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
|
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
|
) : (
|
||||||
<Button
|
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||||
variant="outline"
|
)}
|
||||||
size="sm"
|
<p className="text-sm font-medium text-foreground">
|
||||||
className="mt-3"
|
{searchQuery ? t("empty_search") : t("empty_filtered")}
|
||||||
onClick={() => onSearchChange("")}
|
</p>
|
||||||
>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{t("clear_search")}
|
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
|
||||||
</Button>
|
</p>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
{searchQuery && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
|
||||||
|
{t("clear_search")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{activeFilters > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{t("filters.clear")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -184,8 +532,8 @@ export function ContactList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
(() => {
|
||||||
{sorted.map((contact) => (
|
const renderItem = (contact: ContactCard) => (
|
||||||
<ContactListItem
|
<ContactListItem
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
contact={contact}
|
contact={contact}
|
||||||
@@ -210,11 +558,45 @@ export function ContactList({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleSelection(contact.id);
|
onToggleSelection(contact.id);
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
</div>
|
return groupByLetter ? (
|
||||||
|
<div>
|
||||||
|
{groupedSections.map(({ letter, items }) => (
|
||||||
|
<section key={letter}>
|
||||||
|
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
{items.map(renderItem)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>{sorted.map(renderItem)}</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contextMenu.data && (
|
||||||
|
<ContactContextMenu
|
||||||
|
contact={contextMenu.data}
|
||||||
|
position={contextMenu.position}
|
||||||
|
isOpen={contextMenu.isOpen}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
menuRef={menuRef}
|
||||||
|
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
|
||||||
|
selectedCount={selectedContactIds.size}
|
||||||
|
onOpen={() => onSelectContact(contextMenu.data!.id)}
|
||||||
|
onEdit={() => onEditContact(contextMenu.data!.id)}
|
||||||
|
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||||
|
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||||
|
onBatchExport={onBulkExport}
|
||||||
|
onBatchAddToGroup={onBulkAddToGroup}
|
||||||
|
onBatchDelete={onBulkDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
|
||||||
|
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printContact(contact: ContactCard, displayName?: string): void {
|
||||||
|
const printWindow = window.open("", "_blank");
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
const name = displayName || getContactDisplayName(contact);
|
||||||
|
const photoUri = getContactPhotoUri(contact);
|
||||||
|
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 titles = contact.titles ? Object.values(contact.titles) : [];
|
||||||
|
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
|
||||||
|
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||||
|
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||||
|
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||||
|
|
||||||
|
const rows: string[] = [];
|
||||||
|
const section = (title: string, items: string[]) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
rows.push(`<section><h2>${escapeHtml(title)}</h2><dl>${items.join("")}</dl></section>`);
|
||||||
|
};
|
||||||
|
const row = (label: string, value: string) =>
|
||||||
|
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
|
||||||
|
|
||||||
|
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
|
||||||
|
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
|
||||||
|
section(
|
||||||
|
"Organization",
|
||||||
|
orgs.map(o => {
|
||||||
|
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
|
||||||
|
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
|
||||||
|
return row("Organization", value);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
|
||||||
|
section(
|
||||||
|
"Address",
|
||||||
|
addresses.map(a => {
|
||||||
|
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
|
||||||
|
.map(p => (typeof p === "string" ? p : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
return row(a.label || "Address", parts.join(", "));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
section(
|
||||||
|
"Online",
|
||||||
|
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
|
||||||
|
);
|
||||||
|
section(
|
||||||
|
"Anniversary",
|
||||||
|
anniversaries.map(a => {
|
||||||
|
const date = a.date;
|
||||||
|
let formatted = "";
|
||||||
|
if (typeof date === "object" && date !== null) {
|
||||||
|
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
|
||||||
|
else {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const pd = date as PartialDate;
|
||||||
|
if (pd.year) parts.push(String(pd.year));
|
||||||
|
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
|
||||||
|
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
|
||||||
|
formatted = parts.join("-");
|
||||||
|
}
|
||||||
|
} else if (date) {
|
||||||
|
formatted = String(date);
|
||||||
|
}
|
||||||
|
return row(a.kind || "Date", formatted);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
section("Notes", notes.map(n => row("", n.note || "")));
|
||||||
|
|
||||||
|
const photoTag = photoUri
|
||||||
|
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
|
||||||
|
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${escapeHtml(name || "Contact")}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
|
||||||
|
header { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ccc; }
|
||||||
|
.photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||||
|
.photo.placeholder { background: #e5e7eb; color: #4b5563; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 600; }
|
||||||
|
h1 { margin: 0; font-size: 22px; }
|
||||||
|
section { margin-top: 20px; }
|
||||||
|
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 0 0 8px; }
|
||||||
|
dl { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 6px; column-gap: 16px; font-size: 14px; }
|
||||||
|
dt { color: #555; }
|
||||||
|
dd { margin: 0; word-break: break-word; }
|
||||||
|
@media print { body { margin: 16mm; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
${photoTag}
|
||||||
|
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
|
||||||
|
</header>
|
||||||
|
${rows.join("")}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
printWindow.document.write(html);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
|
|||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onShareAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||||
|
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||||
onRenameKeyword?: (keyword: string) => void;
|
onRenameKeyword?: (keyword: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,9 @@ export function ContactsSidebar({
|
|||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
onRenameAddressBook,
|
onRenameAddressBook,
|
||||||
|
onShareAddressBook,
|
||||||
|
onCreateContactInBook,
|
||||||
|
onDeleteAddressBook,
|
||||||
onRenameKeyword,
|
onRenameKeyword,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
@@ -288,7 +294,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -430,7 +436,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,24 +444,65 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address book context menu */}
|
{/* Address book context menu */}
|
||||||
{bookContextMenu.data && onRenameAddressBook && (
|
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
|
||||||
<ContextMenu
|
const book = bookContextMenu.data;
|
||||||
ref={bookMenuRef}
|
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
|
||||||
isOpen={bookContextMenu.isOpen}
|
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
|
||||||
position={bookContextMenu.position}
|
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
|
||||||
onClose={closeBookContextMenu}
|
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
|
||||||
>
|
const showSeparator = (canCreate || canRename || canShare) && canDelete;
|
||||||
<ContextMenuItem
|
return (
|
||||||
icon={Pencil}
|
<ContextMenu
|
||||||
label={t("address_books.rename")}
|
ref={bookMenuRef}
|
||||||
onClick={() => {
|
isOpen={bookContextMenu.isOpen}
|
||||||
const book = bookContextMenu.data!;
|
position={bookContextMenu.position}
|
||||||
closeBookContextMenu();
|
onClose={closeBookContextMenu}
|
||||||
onRenameAddressBook(book);
|
>
|
||||||
}}
|
{canCreate && (
|
||||||
/>
|
<ContextMenuItem
|
||||||
</ContextMenu>
|
icon={UserPlus}
|
||||||
)}
|
label={t("address_books.new_contact_in_book")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onCreateContactInBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canRename && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("address_books.rename")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onRenameAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={t("address_books.share")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onShareAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("address_books.delete")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onDeleteAddressBook(book);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Keyword (category) context menu */}
|
{/* Keyword (category) context menu */}
|
||||||
{keywordContextMenu.data && onRenameKeyword && (
|
{keywordContextMenu.data && onRenameKeyword && (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslations, useFormatter } from 'next-intl';
|
import { useTranslations, useFormatter } from 'next-intl';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
@@ -372,6 +373,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
|
|
||||||
const attachment = findCalendarAttachment(email);
|
const attachment = findCalendarAttachment(email);
|
||||||
|
|
||||||
@@ -442,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
||||||
const isCancellation = method === 'cancel';
|
const isCancellation = method === 'cancel';
|
||||||
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
|
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
|
||||||
|
const canCollapse = method === 'reply';
|
||||||
|
const showDetails = !canCollapse || !isCollapsed;
|
||||||
const allowsRsvp = method === 'request';
|
const allowsRsvp = method === 'request';
|
||||||
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
||||||
|
|
||||||
@@ -527,7 +531,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|| null;
|
|| null;
|
||||||
|
|
||||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
||||||
// Called after updating the local calendar event. Best-effort — if it
|
// Called after updating the local calendar event. Best-effort - if it
|
||||||
// fails we still report the RSVP as sent since the calendar was updated.
|
// fails we still report the RSVP as sent since the calendar was updated.
|
||||||
const sendImipReply = async () => {
|
const sendImipReply = async () => {
|
||||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
||||||
@@ -750,9 +754,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
|
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{canCollapse && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||||
|
title={isCollapsed ? t('expand') : t('collapse')}
|
||||||
|
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
{showDetails && (
|
||||||
<div className="px-4 py-3 space-y-2.5">
|
<div className="px-4 py-3 space-y-2.5">
|
||||||
<div className="lg:flex lg:gap-6">
|
<div className="lg:flex lg:gap-6">
|
||||||
{/* Left: Event info */}
|
{/* Left: Event info */}
|
||||||
@@ -894,8 +911,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
{showDetails && (
|
||||||
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
|
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
|
||||||
{canRespond && (
|
{canRespond && (
|
||||||
<>
|
<>
|
||||||
@@ -1020,6 +1039,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
||||||
import { cn, formatFileSize, formatDateTime } from "@/lib/utils";
|
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
@@ -65,7 +66,7 @@ interface EmailComposerProps {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onDiscardDraft?: (draftId: string) => void;
|
onDiscardDraft?: (draftId: string) => void;
|
||||||
@@ -84,9 +85,22 @@ interface EmailComposerProps {
|
|||||||
body?: string;
|
body?: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
receivedAt?: string;
|
receivedAt?: string;
|
||||||
|
accountId?: string;
|
||||||
|
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ComposerAttachment = {
|
||||||
|
file?: File;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
blobId?: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
abortController?: AbortController;
|
||||||
|
};
|
||||||
|
|
||||||
export function EmailComposer({
|
export function EmailComposer({
|
||||||
onSend,
|
onSend,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -199,7 +213,22 @@ export function EmailComposer({
|
|||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>("");
|
const lastSavedDataRef = useRef<string>("");
|
||||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||||
|
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||||
|
return replyTo.attachments
|
||||||
|
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||||
|
// (matches the viewer's hideInlineImageAttachments logic).
|
||||||
|
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||||
|
.map(att => ({
|
||||||
|
name: att.name || 'attachment',
|
||||||
|
type: att.type || 'application/octet-stream',
|
||||||
|
size: att.size,
|
||||||
|
blobId: att.blobId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||||
@@ -254,12 +283,28 @@ export function EmailComposer({
|
|||||||
|
|
||||||
if (matchedIdentityId) {
|
if (matchedIdentityId) {
|
||||||
setSelectedIdentityId(matchedIdentityId);
|
setSelectedIdentityId(matchedIdentityId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: match identity by the account's email when replying from unified view
|
||||||
|
if (replyTo?.accountId) {
|
||||||
|
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
|
||||||
|
if (account?.email) {
|
||||||
|
const accountEmail = account.email.trim().toLowerCase();
|
||||||
|
const accountIdentity = identities.find(
|
||||||
|
(identity) => identity.email.trim().toLowerCase() === accountEmail
|
||||||
|
);
|
||||||
|
if (accountIdentity) {
|
||||||
|
setSelectedIdentityId(accountIdentity.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
autoSelectReplyIdentity,
|
autoSelectReplyIdentity,
|
||||||
identities,
|
identities,
|
||||||
initialData?.selectedIdentityId,
|
initialData?.selectedIdentityId,
|
||||||
mode,
|
mode,
|
||||||
|
replyTo?.accountId,
|
||||||
replyTo?.bcc,
|
replyTo?.bcc,
|
||||||
replyTo?.cc,
|
replyTo?.cc,
|
||||||
replyTo?.to,
|
replyTo?.to,
|
||||||
@@ -306,7 +351,7 @@ export function EmailComposer({
|
|||||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
||||||
|
|
||||||
// Track initial values for dirty detection (captured once on first render)
|
// Track initial values for dirty detection (captured once on first render)
|
||||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 });
|
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
|
||||||
const isDirtyRef = useRef(false);
|
const isDirtyRef = useRef(false);
|
||||||
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
|
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
|
||||||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
|
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
|
||||||
@@ -505,9 +550,16 @@ export function EmailComposer({
|
|||||||
const addFiles = useCallback(async (files: File[]) => {
|
const addFiles = useCallback(async (files: File[]) => {
|
||||||
if (!client || files.length === 0) return;
|
if (!client || files.length === 0) return;
|
||||||
|
|
||||||
const newAttachments = files.map(file => {
|
const newAttachments: ComposerAttachment[] = files.map(file => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
return { file, uploading: true, abortController: controller };
|
return {
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
uploading: true,
|
||||||
|
abortController: controller,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
setAttachments(prev => [...prev, ...newAttachments]);
|
setAttachments(prev => [...prev, ...newAttachments]);
|
||||||
|
|
||||||
@@ -542,18 +594,38 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
const handleImageUpload = useCallback(async (
|
||||||
return new Promise((resolve) => {
|
file: File,
|
||||||
const reader = new FileReader();
|
): Promise<{ src: string; cid: string } | null> => {
|
||||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
if (!client) return null;
|
||||||
reader.onerror = () => {
|
try {
|
||||||
debug.error(`Failed to read inline image ${file.name}`);
|
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||||
toast.error(t('upload_failed', { filename: file.name }));
|
const reader = new FileReader();
|
||||||
resolve(null);
|
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||||
};
|
reader.onerror = () => resolve(null);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
}, [t]);
|
const [{ blobId }, dataUrl] = await Promise.all([
|
||||||
|
client.uploadBlob(file),
|
||||||
|
readAsDataUrl,
|
||||||
|
]);
|
||||||
|
if (!dataUrl) throw new Error('Failed to read image as data URL');
|
||||||
|
const cid = `${generateUUID()}@webmail`;
|
||||||
|
inlineImagesRef.current.push({
|
||||||
|
cid,
|
||||||
|
blobId,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
dataUrl,
|
||||||
|
});
|
||||||
|
return { src: dataUrl, cid };
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||||
|
toast.error(t('upload_failed', { filename: file.name }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [client, t]);
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return;
|
if (!event.target.files) return;
|
||||||
@@ -630,9 +702,9 @@ export function EmailComposer({
|
|||||||
.filter(att => att.blobId && !att.uploading)
|
.filter(att => att.blobId && !att.uploading)
|
||||||
.map(att => ({
|
.map(att => ({
|
||||||
blobId: att.blobId!,
|
blobId: att.blobId!,
|
||||||
name: att.file.name,
|
name: att.name,
|
||||||
type: att.file.type,
|
type: att.type,
|
||||||
size: att.file.size,
|
size: att.size,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create a hash of current data to compare with last saved
|
// Create a hash of current data to compare with last saved
|
||||||
@@ -733,6 +805,41 @@ export function EmailComposer({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
|
||||||
|
// references so recipient clients that strip data URIs can still render them.
|
||||||
|
const rewriteInlineImages = (html: string): {
|
||||||
|
html: string;
|
||||||
|
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
||||||
|
} => {
|
||||||
|
const known = inlineImagesRef.current;
|
||||||
|
if (known.length === 0) return { html, attachments: [] };
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||||
|
const used = new Map<string, typeof known[number]>();
|
||||||
|
|
||||||
|
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||||
|
const cid = img.getAttribute('data-cid');
|
||||||
|
if (!cid) return;
|
||||||
|
const entry = known.find((e) => e.cid === cid);
|
||||||
|
if (!entry) return;
|
||||||
|
img.setAttribute('src', `cid:${cid}`);
|
||||||
|
img.removeAttribute('data-cid');
|
||||||
|
used.set(cid, entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: doc.body.innerHTML,
|
||||||
|
attachments: Array.from(used.values()).map((e) => ({
|
||||||
|
blobId: e.blobId,
|
||||||
|
name: e.name,
|
||||||
|
type: e.type,
|
||||||
|
size: e.size,
|
||||||
|
disposition: 'inline' as const,
|
||||||
|
cid: e.cid,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async (skipAttachmentCheck = false) => {
|
const handleSend = async (skipAttachmentCheck = false) => {
|
||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
@@ -803,9 +910,11 @@ export function EmailComposer({
|
|||||||
? appendPlainTextSignature(body, currentIdentity)
|
? appendPlainTextSignature(body, currentIdentity)
|
||||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||||
|
|
||||||
|
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||||
const finalHtmlBody = plainTextMode
|
const finalHtmlBody = plainTextMode
|
||||||
? undefined
|
? undefined
|
||||||
: `<div>${body}</div>${buildSignatureHtml()}`;
|
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
|
||||||
|
const inlineAttachments = rewritten?.attachments ?? [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
@@ -834,19 +943,29 @@ export function EmailComposer({
|
|||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
if (att.error || att.uploading) continue;
|
if (att.error || att.uploading) continue;
|
||||||
let content: ArrayBuffer;
|
let content: ArrayBuffer;
|
||||||
if (att.file.size > 0) {
|
if (att.file && att.file.size > 0) {
|
||||||
content = await att.file.arrayBuffer();
|
content = await att.file.arrayBuffer();
|
||||||
} else if (att.blobId && client) {
|
} else if (att.blobId && client) {
|
||||||
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type);
|
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
mimeAttachments.push({
|
mimeAttachments.push({
|
||||||
filename: att.file.name,
|
filename: att.name,
|
||||||
contentType: att.file.type || 'application/octet-stream',
|
contentType: att.type || 'application/octet-stream',
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const inline of inlineAttachments) {
|
||||||
|
if (!client) break;
|
||||||
|
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
|
||||||
|
mimeAttachments.push({
|
||||||
|
filename: inline.name,
|
||||||
|
contentType: inline.type,
|
||||||
|
content,
|
||||||
|
cid: inline.cid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Build canonical MIME
|
// 4. Build canonical MIME
|
||||||
const mimeBytes = buildMimeMessage({
|
const mimeBytes = buildMimeMessage({
|
||||||
@@ -906,9 +1025,10 @@ export function EmailComposer({
|
|||||||
} else {
|
} else {
|
||||||
// Standard JMAP send path
|
// Standard JMAP send path
|
||||||
// Collect uploaded attachment blobIds for the send request
|
// Collect uploaded attachment blobIds for the send request
|
||||||
const uploadedAttachments = attachments
|
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
|
||||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||||
.map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size }));
|
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||||
|
uploadedAttachments.push(...inlineAttachments);
|
||||||
|
|
||||||
await onSend?.({
|
await onSend?.({
|
||||||
to: toAddresses,
|
to: toAddresses,
|
||||||
@@ -980,8 +1100,14 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={cn("flex h-full bg-background", className)}>
|
||||||
|
<PluginSlot
|
||||||
|
name="composer-sidebar"
|
||||||
|
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||||
|
/>
|
||||||
|
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
|
||||||
data-tour="composer"
|
data-tour="composer"
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -1288,9 +1414,9 @@ export function EmailComposer({
|
|||||||
) : (
|
) : (
|
||||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span>
|
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
({formatFileSize(att.file.size)})
|
({formatFileSize(att.size)})
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeAttachment(index)}
|
onClick={() => removeAttachment(index)}
|
||||||
@@ -1554,6 +1680,11 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<PluginSlot
|
||||||
|
name="composer-sidebar-right"
|
||||||
|
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ interface EmailContextMenuProps {
|
|||||||
// Batch actions
|
// Batch actions
|
||||||
onBatchMarkAsRead?: (read: boolean) => void;
|
onBatchMarkAsRead?: (read: boolean) => void;
|
||||||
onBatchDelete?: () => void;
|
onBatchDelete?: () => void;
|
||||||
|
onBatchArchive?: () => void;
|
||||||
onBatchMoveToMailbox?: (mailboxId: string) => void;
|
onBatchMoveToMailbox?: (mailboxId: string) => void;
|
||||||
onBatchMarkAsSpam?: () => void;
|
onBatchMarkAsSpam?: () => void;
|
||||||
onBatchUndoSpam?: () => void;
|
onBatchUndoSpam?: () => void;
|
||||||
@@ -127,6 +128,7 @@ export function EmailContextMenu({
|
|||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
onBatchMarkAsRead,
|
onBatchMarkAsRead,
|
||||||
onBatchDelete,
|
onBatchDelete,
|
||||||
|
onBatchArchive,
|
||||||
onBatchMoveToMailbox,
|
onBatchMoveToMailbox,
|
||||||
onBatchMarkAsSpam,
|
onBatchMarkAsSpam,
|
||||||
onBatchUndoSpam,
|
onBatchUndoSpam,
|
||||||
@@ -235,8 +237,10 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Archive}
|
icon={Archive}
|
||||||
label={t("archive")}
|
label={t("archive")}
|
||||||
onClick={() => handleAction(onArchive!)}
|
onClick={() =>
|
||||||
disabled={!onArchive}
|
handleAction(showBatchActions ? onBatchArchive! : onArchive!)
|
||||||
|
}
|
||||||
|
disabled={showBatchActions ? !onBatchArchive : !onArchive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete */}
|
{/* Delete */}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
@@ -49,11 +50,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions from settings
|
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||||
const colorTagIds = getEmailColorTags(email.keywords);
|
const colorTagIds = getEmailColorTags(email.keywords);
|
||||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
// Use first tag for background coloring
|
// Use first tag for background coloring
|
||||||
const keywordDef = keywordDefs[0] ?? null;
|
const keywordDef = keywordDefs[0] ?? null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
@@ -99,7 +101,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
selected && !colorTag && "shadow-sm",
|
selected && !colorTag && "shadow-sm",
|
||||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||||
|
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
isUnread && !selected && !colorTag && "bg-warning/10",
|
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||||
// Add visual feedback for checked state
|
// Add visual feedback for checked state
|
||||||
@@ -163,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -284,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||||
{showPreview && density !== 'extra-compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
isUnread
|
isUnread
|
||||||
@@ -302,7 +306,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function EmailList({
|
|||||||
batchMarkAsRead,
|
batchMarkAsRead,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchMoveToMailbox,
|
batchMoveToMailbox,
|
||||||
|
batchArchive,
|
||||||
batchMarkAsSpam,
|
batchMarkAsSpam,
|
||||||
batchUndoSpam,
|
batchUndoSpam,
|
||||||
loadMoreEmails,
|
loadMoreEmails,
|
||||||
@@ -176,7 +177,7 @@ export function EmailList({
|
|||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await batchDelete(client);
|
await batchDelete(client, isInTrash);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setIsProcessing(false), 500);
|
setTimeout(() => setIsProcessing(false), 500);
|
||||||
}
|
}
|
||||||
@@ -496,6 +497,14 @@ export function EmailList({
|
|||||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
|
onBatchArchive={async () => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await batchArchive(client);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to batch archive:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||||
onBatchMarkAsSpam={async () => {
|
onBatchMarkAsSpam={async () => {
|
||||||
if (client) {
|
if (client) {
|
||||||
|
|||||||
+235
-205
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
|
|||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -85,7 +85,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
|
|||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
import { useTour } from "@/components/tour/tour-provider";
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||||
import { RecipientPopover } from "./recipient-popover";
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { SmimeStatusBanner } from "./smime-status-banner";
|
import { SmimeStatusBanner } from "./smime-status-banner";
|
||||||
@@ -740,7 +740,7 @@ function ContactSidebarPanel({
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{o.units && o.units.length > 0 && (
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -896,6 +896,7 @@ export function EmailViewer({
|
|||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
|
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
@@ -1170,7 +1171,7 @@ export function EmailViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already read — record that so manual unread toggle won't re-trigger auto-mark
|
// Already read - record that so manual unread toggle won't re-trigger auto-mark
|
||||||
if (email.keywords?.$seen) {
|
if (email.keywords?.$seen) {
|
||||||
autoMarkedEmailRef.current = email.id;
|
autoMarkedEmailRef.current = email.id;
|
||||||
return;
|
return;
|
||||||
@@ -1644,7 +1645,7 @@ export function EmailViewer({
|
|||||||
// Check if inner content is also signed
|
// Check if inner content is also signed
|
||||||
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
||||||
if (nestedSignedData) {
|
if (nestedSignedData) {
|
||||||
// Nested sign-then-encrypt — verify inner signature
|
// Nested sign-then-encrypt - verify inner signature
|
||||||
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
||||||
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
||||||
source: nestedSignedData.source,
|
source: nestedSignedData.source,
|
||||||
@@ -1853,7 +1854,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Check if the email already has a usable HTML body with real content
|
// Check if the email already has a usable HTML body with real content
|
||||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||||
// boilerplate (CSS + ) — treat these as effectively empty.
|
// boilerplate (CSS + ) - treat these as effectively empty.
|
||||||
const htmlPartId = email.htmlBody?.[0]?.partId;
|
const htmlPartId = email.htmlBody?.[0]?.partId;
|
||||||
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
||||||
let hasRealHtmlBody = !!htmlValue;
|
let hasRealHtmlBody = !!htmlValue;
|
||||||
@@ -1898,7 +1899,7 @@ export function EmailViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
debug.log('email', 'TNEF parse result - htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||||
|
|
||||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||||
setTnefHtml(parsed.htmlBody);
|
setTnefHtml(parsed.htmlBody);
|
||||||
@@ -1912,7 +1913,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||||
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted - the winmail.dat may use an unsupported format');
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
@@ -1975,7 +1976,7 @@ export function EmailViewer({
|
|||||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
|
|
||||||
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
debug.log('email', 'Embedded RFC822 parsed - html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||||
', attachments:', parsed.attachments?.length ?? 0);
|
', attachments:', parsed.attachments?.length ?? 0);
|
||||||
|
|
||||||
@@ -2074,21 +2075,30 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
||||||
if (smimeDecryptedAttachments.length > 0) {
|
if (smimeDecryptedAttachments.length > 0) {
|
||||||
return smimeDecryptedAttachments.map((attachment, index) => ({
|
return smimeDecryptedAttachments
|
||||||
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
|
.filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/')))
|
||||||
name: attachment.filename,
|
.map((attachment, index) => ({
|
||||||
type: attachment.mimeType || 'application/octet-stream',
|
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
|
||||||
size: getPostalMimeAttachmentSize(attachment),
|
name: attachment.filename,
|
||||||
cid: attachment.contentId,
|
type: attachment.mimeType || 'application/octet-stream',
|
||||||
decryptedAttachment: attachment,
|
size: getPostalMimeAttachmentSize(attachment),
|
||||||
}));
|
cid: attachment.contentId,
|
||||||
|
decryptedAttachment: attachment,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
|
||||||
const jmapAttachments = (email?.attachments ?? [])
|
const jmapAttachments = (email?.attachments ?? [])
|
||||||
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
||||||
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
||||||
// Hide message/rfc822 when we have unwrapped the embedded email
|
// Hide message/rfc822 when we have unwrapped the embedded email
|
||||||
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
||||||
|
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
|
||||||
|
// banner is shown - prevents raw ICS files appearing as spurious attachments.
|
||||||
|
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
|
||||||
|
// Hide inline cid-referenced images when the user has opted to keep them
|
||||||
|
// out of the attachment list (default on): these are embedded in the body.
|
||||||
|
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||||
.map((attachment, index) => ({
|
.map((attachment, index) => ({
|
||||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||||
name: attachment.name || null,
|
name: attachment.name || null,
|
||||||
@@ -2119,7 +2129,7 @@ export function EmailViewer({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
||||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]);
|
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
|
||||||
|
|
||||||
// Generate email source for viewing
|
// Generate email source for viewing
|
||||||
const generateEmailSource = (email: Email): string => {
|
const generateEmailSource = (email: Email): string => {
|
||||||
@@ -2312,7 +2322,7 @@ export function EmailViewer({
|
|||||||
let blockedExternalContent = false;
|
let blockedExternalContent = false;
|
||||||
|
|
||||||
// Use shared sanitization config as base (more secure)
|
// Use shared sanitization config as base (more secure)
|
||||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
const sanitizeConfig = { ...EMAIL_IFRAME_SANITIZE_CONFIG };
|
||||||
|
|
||||||
// Check if sender is trusted (localStorage list or address book)
|
// Check if sender is trusted (localStorage list or address book)
|
||||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||||
@@ -2393,16 +2403,8 @@ export function EmailViewer({
|
|||||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||||
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
||||||
|
|
||||||
// Convert plain text to HTML with proper formatting
|
|
||||||
// Uses white-space: pre-wrap on the container to preserve newlines/whitespace
|
|
||||||
const htmlFromText = textContent
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: htmlFromText,
|
html: plainTextToSafeHtml(textContent),
|
||||||
isHtml: false
|
isHtml: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2436,42 +2438,27 @@ export function EmailViewer({
|
|||||||
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true };
|
||||||
}
|
}
|
||||||
if (smimeDecryptedText) {
|
if (smimeDecryptedText) {
|
||||||
const htmlFromText = smimeDecryptedText
|
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
// TNEF (winmail.dat) extracted content
|
// TNEF (winmail.dat) extracted content
|
||||||
if (tnefHtml) {
|
if (tnefHtml) {
|
||||||
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true };
|
||||||
}
|
}
|
||||||
if (tnefText) {
|
if (tnefText) {
|
||||||
const htmlFromText = tnefText
|
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
// Embedded message/rfc822 unwrapped content
|
// Embedded message/rfc822 unwrapped content
|
||||||
if (embeddedEmailHtml) {
|
if (embeddedEmailHtml) {
|
||||||
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true };
|
||||||
}
|
}
|
||||||
if (embeddedEmailText) {
|
if (embeddedEmailText) {
|
||||||
const htmlFromText = embeddedEmailText
|
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
return emailContent;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||||
@@ -2616,7 +2603,7 @@ export function EmailViewer({
|
|||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||||
img { max-width: 100% !important; height: auto !important; }
|
img { max-width: 100% !important; height: auto !important; }
|
||||||
a { color: #1a73e8; }
|
a { color: #1a73e8; }
|
||||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||||
@@ -2893,7 +2880,7 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
{/* Left: Reply actions */}
|
{/* Left: Reply actions */}
|
||||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||||
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2957,7 +2944,7 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="toolbar-actions" />
|
<PluginSlot name="toolbar-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
||||||
@@ -3054,7 +3041,7 @@ export function EmailViewer({
|
|||||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tag Picker — hidden on mobile, overflows to More menu */}
|
{/* Tag Picker - hidden on mobile, overflows to More menu */}
|
||||||
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
||||||
<div className="w-px h-5 bg-border mx-0.5" />
|
<div className="w-px h-5 bg-border mx-0.5" />
|
||||||
<div ref={tagMenuRef} className="relative">
|
<div ref={tagMenuRef} className="relative">
|
||||||
@@ -3070,13 +3057,13 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
{currentColors.slice(0, 3).map((tagId) => {
|
{currentColors.slice(0, 3).map((tagId) => {
|
||||||
const kw = emailKeywords.find(k => k.id === tagId);
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
|
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{showToolbarLabels && currentColors.length === 1 && (
|
{showToolbarLabels && currentColors.length === 1 && (
|
||||||
<span className="text-xs font-medium text-foreground">
|
<span className="text-xs font-medium text-foreground">
|
||||||
{emailKeywords.find(k => k.id === currentColors[0])?.label}
|
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -3123,7 +3110,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spam — hidden on mobile, overflows to More menu */}
|
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3145,7 +3132,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toggle read state — hidden on mobile, overflows to More menu */}
|
{/* Toggle read state - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3158,7 +3145,7 @@ export function EmailViewer({
|
|||||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Print — hidden on mobile, overflows to More menu */}
|
{/* Print - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3172,7 +3159,7 @@ export function EmailViewer({
|
|||||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* View source — hidden on mobile, overflows to More menu */}
|
{/* View source - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3200,7 +3187,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* More menu — click-based */}
|
{/* More menu - click-based */}
|
||||||
<div ref={moreMenuRef} className="relative">
|
<div ref={moreMenuRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3246,7 +3233,7 @@ export function EmailViewer({
|
|||||||
<Archive className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
{t('archive')}
|
{t('archive')}
|
||||||
</button>
|
</button>
|
||||||
{/* Overflow: move to folder — submenu */}
|
{/* Overflow: move to folder - submenu */}
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('move')}
|
onMouseEnter={() => setMoreMenuSub('move')}
|
||||||
@@ -3298,7 +3285,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overflow: tag — submenu */}
|
{/* Overflow: tag - submenu */}
|
||||||
{colorOptions.length > 0 && (
|
{colorOptions.length > 0 && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||||
@@ -3449,70 +3436,172 @@ export function EmailViewer({
|
|||||||
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
{moreMenuSub ? (
|
||||||
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
|
<button
|
||||||
|
onClick={() => setMoreMenuSub(null)}
|
||||||
|
className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }} className="h-9 w-9">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
<button
|
{moreMenuSub === null && (
|
||||||
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Archive className="w-5 h-5" />
|
|
||||||
{t('archive')}
|
|
||||||
</button>
|
|
||||||
{/* Move to folder */}
|
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Archive className="w-5 h-5" />
|
||||||
|
{t('archive')}
|
||||||
|
</button>
|
||||||
|
{/* Move to folder (opens sub-view) */}
|
||||||
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreMenuSub('move')}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<FolderInput className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{t('move_to')}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Tag (opens sub-view) */}
|
||||||
|
{colorOptions.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreMenuSub('tag')}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Tag className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{t('tag')}</span>
|
||||||
|
{currentColors.length > 0 && (
|
||||||
|
<div className="flex -space-x-1 mr-1">
|
||||||
|
{currentColors.slice(0, 3).map((c) => {
|
||||||
|
const opt = colorOptions.find((o) => o.value === c);
|
||||||
|
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Spam */}
|
||||||
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isInJunkFolder ? (
|
||||||
|
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Toggle read state */}
|
||||||
|
<button
|
||||||
|
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
|
||||||
|
{isUnread ? t('mark_read') : t('mark_unread')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Printer className="w-5 h-5" />
|
||||||
|
{t('print')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Code className="w-5 h-5" />
|
||||||
|
{t('view_source')}
|
||||||
|
</button>
|
||||||
|
{effectiveEmailContent.isHtml && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
{isDark ? 'View in light mode' : 'View in dark mode'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
|
<button
|
||||||
{(() => {
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
||||||
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
return nodes.map((node) => {
|
>
|
||||||
const Icon = getMoveMailboxIcon(node.role);
|
<Download className="w-5 h-5" />
|
||||||
const isTarget = moveTargetIds.has(node.id);
|
{t('export_email')}
|
||||||
return (
|
</button>
|
||||||
<div key={node.id}>
|
<button
|
||||||
{isTarget ? (
|
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
|
||||||
<button
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }}
|
>
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
<Upload className="w-5 h-5" />
|
||||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
{t('import_email')}
|
||||||
>
|
</button>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
{onShowShortcuts && (
|
||||||
<span className="truncate">{node.name}</span>
|
<button
|
||||||
</button>
|
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
||||||
) : (
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
<div
|
>
|
||||||
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
<Keyboard className="w-5 h-5" />
|
||||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
{t('keyboard_shortcuts')}
|
||||||
>
|
</button>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
)}
|
||||||
<span>{node.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return renderMobileNodes(moveTree);
|
|
||||||
})()}
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Tags */}
|
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
|
||||||
{colorOptions.length > 0 && (
|
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const Icon = getMoveMailboxIcon(node.role);
|
||||||
|
const isTarget = moveTargetIds.has(node.id);
|
||||||
|
return (
|
||||||
|
<div key={node.id}>
|
||||||
|
{isTarget ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
||||||
|
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return renderMobileNodes(moveTree);
|
||||||
|
})()}
|
||||||
|
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
|
||||||
{colorOptions.map((option) => {
|
{colorOptions.map((option) => {
|
||||||
const isActive = currentColors.includes(option.value);
|
const isActive = currentColors.includes(option.value);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||||
isActive && "bg-accent font-medium"
|
isActive && "bg-accent font-medium"
|
||||||
@@ -3526,85 +3615,15 @@ export function EmailViewer({
|
|||||||
})}
|
})}
|
||||||
{currentColors.length > 0 && (
|
{currentColors.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 flex-shrink-0" />
|
<X className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{t('remove_color')}</span>
|
<span>{t('remove_color')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Spam */}
|
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
|
||||||
<button
|
|
||||||
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isInJunkFolder ? (
|
|
||||||
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
||||||
) : (
|
|
||||||
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
||||||
)}
|
|
||||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Toggle read state */}
|
|
||||||
<button
|
|
||||||
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
|
|
||||||
{isUnread ? t('mark_read') : t('mark_unread')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Printer className="w-5 h-5" />
|
|
||||||
{t('print')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Code className="w-5 h-5" />
|
|
||||||
{t('view_source')}
|
|
||||||
</button>
|
|
||||||
{effectiveEmailContent.isHtml && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
|
||||||
{isDark ? 'View in light mode' : 'View in dark mode'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<button
|
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
{t('export_email')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Upload className="w-5 h-5" />
|
|
||||||
{t('import_email')}
|
|
||||||
</button>
|
|
||||||
{onShowShortcuts && (
|
|
||||||
<button
|
|
||||||
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Keyboard className="w-5 h-5" />
|
|
||||||
{t('keyboard_shortcuts')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3641,7 +3660,7 @@ export function EmailViewer({
|
|||||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||||
{/* Back button (for below-subject mode on tablet) */}
|
{/* Back button (for below-subject mode on tablet) */}
|
||||||
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -3674,11 +3693,11 @@ export function EmailViewer({
|
|||||||
{currentColors.length > 0 && (
|
{currentColors.length > 0 && (
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
{currentColors.map((tagId) => {
|
{currentColors.map((tagId) => {
|
||||||
const kw = emailKeywords.find(k => k.id === tagId);
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||||
return dotClass ? (
|
return (
|
||||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||||
) : null;
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3689,8 +3708,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Date/time on the right of subject row */}
|
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||||
<div className="flex-shrink-0 text-right">
|
<div className="hidden sm:block flex-shrink-0 text-right">
|
||||||
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
@@ -4035,7 +4054,7 @@ export function EmailViewer({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||||
Spam Score
|
Spam Score
|
||||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better — scores above 5 are likely spam" />
|
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-xs",
|
"text-xs",
|
||||||
@@ -4497,6 +4516,17 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Date/time + size on the right (mobile) */}
|
||||||
|
<div className="sm:hidden flex-shrink-0 text-right ml-2">
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
{email.size > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground/60">
|
||||||
|
{formatFileSize(email.size)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4787,61 +4817,61 @@ export function EmailViewer({
|
|||||||
onClick={onNavigatePrev}
|
onClick={onNavigatePrev}
|
||||||
disabled={!onNavigatePrev}
|
disabled={!onNavigatePrev}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||||
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||||
)}
|
)}
|
||||||
aria-label={t('tooltips.previous')}
|
aria-label={t('tooltips.previous')}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('previous')}</span>
|
||||||
</button>
|
</button>
|
||||||
{isDraft && onEditDraft ? (
|
{isDraft && onEditDraft ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => onEditDraft()}
|
onClick={() => onEditDraft()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
|
||||||
aria-label={t('tooltips.edit_draft')}
|
aria-label={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
<EditIcon className="w-5 h-5" />
|
<EditIcon className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('edit_draft')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('edit_draft')}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onReply?.()}
|
onClick={() => onReply?.()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.reply')}
|
aria-label={t('tooltips.reply')}
|
||||||
>
|
>
|
||||||
<Reply className="w-5 h-5" />
|
<Reply className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onReplyAll}
|
onClick={onReplyAll}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.reply_all')}
|
aria-label={t('tooltips.reply_all')}
|
||||||
>
|
>
|
||||||
<ReplyAll className="w-5 h-5" />
|
<ReplyAll className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onForward}
|
onClick={onForward}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.forward')}
|
aria-label={t('tooltips.forward')}
|
||||||
>
|
>
|
||||||
<Forward className="w-5 h-5" />
|
<Forward className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
|
||||||
</button>
|
</button>
|
||||||
</>)}
|
</>)}
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateNext}
|
onClick={onNavigateNext}
|
||||||
disabled={!onNavigateNext}
|
disabled={!onNavigateNext}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||||
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||||
)}
|
)}
|
||||||
aria-label={t('tooltips.next')}
|
aria-label={t('tooltips.next')}
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('next')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('next')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
|
|||||||
email: string;
|
email: string;
|
||||||
/** Display label override (e.g. "me") */
|
/** Display label override (e.g. "me") */
|
||||||
displayLabel?: string;
|
displayLabel?: string;
|
||||||
/** Called when user clicks "View contact" — receives the contact and email */
|
/** Called when user clicks "View contact" - receives the contact and email */
|
||||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export const ResizableImage = Node.create({
|
|||||||
alt: { default: null },
|
alt: { default: null },
|
||||||
title: { default: null },
|
title: { default: null },
|
||||||
width: { default: null },
|
width: { default: null },
|
||||||
|
cid: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||||
|
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ import {
|
|||||||
Heading2,
|
Heading2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface InlineImageUpload {
|
||||||
|
src: string;
|
||||||
|
cid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
onChange: (html: string) => void;
|
onChange: (html: string) => void;
|
||||||
onImageUpload?: (file: File) => Promise<string | null>;
|
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
hasError?: boolean;
|
hasError?: boolean;
|
||||||
@@ -120,11 +125,11 @@ export function RichTextEditor({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
upload(file).then((url) => {
|
upload(file).then((result) => {
|
||||||
if (url) {
|
if (result) {
|
||||||
const { state } = view;
|
const { state } = view;
|
||||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||||||
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
}
|
}
|
||||||
@@ -141,10 +146,10 @@ export function RichTextEditor({
|
|||||||
if (imageFiles.length === 0) return false;
|
if (imageFiles.length === 0) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
upload(file).then((url) => {
|
upload(file).then((result) => {
|
||||||
if (url) {
|
if (result) {
|
||||||
const { state } = view;
|
const { state } = view;
|
||||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||||||
const tr = state.tr.replaceSelectionWith(node);
|
const tr = state.tr.replaceSelectionWith(node);
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
@@ -237,6 +237,7 @@ function EmailCard({
|
|||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||||
|
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||||
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
@@ -419,12 +420,7 @@ function EmailCard({
|
|||||||
// Plain text fallback
|
// Plain text fallback
|
||||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||||
const htmlEscaped = text
|
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
|
|
||||||
return { html: htmlEscaped, isHtml: false };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,10 +545,14 @@ function EmailCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
{email.attachments && email.attachments.length > 0 && (
|
{(() => {
|
||||||
|
const visibleAttachments = (email.attachments ?? []).filter(
|
||||||
|
att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))
|
||||||
|
);
|
||||||
|
return visibleAttachments.length > 0 && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{email.attachments.map((attachment, idx) => {
|
{visibleAttachments.map((attachment, idx) => {
|
||||||
const Icon = getFileIcon(attachment.name, attachment.type);
|
const Icon = getFileIcon(attachment.name, attachment.type);
|
||||||
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
@@ -581,7 +581,8 @@ function EmailCard({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="px-4 pb-4 flex gap-2">
|
<div className="px-4 pb-4 flex gap-2">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
|
|||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
@@ -63,13 +64,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
|
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||||
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions
|
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||||
const tagIds = getEmailColorTags(email.keywords);
|
const tagIds = getEmailColorTags(email.keywords);
|
||||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||||
const resolvedColorTag = (() => {
|
const resolvedColorTag = (() => {
|
||||||
if (colorTag) return colorTag;
|
if (colorTag) return colorTag;
|
||||||
@@ -129,7 +135,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
selected && !resolvedColorTag && "shadow-sm",
|
selected && !resolvedColorTag && "shadow-sm",
|
||||||
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||||
|
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||||
@@ -177,6 +184,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -184,6 +192,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{isFocusedMailLayout ? (
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
{isUnifiedView && email.accountId && accountColor && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: accountColor }}
|
||||||
|
title={email.accountLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
@@ -228,6 +243,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{isUnifiedView && email.accountId && accountColor && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: accountColor }}
|
||||||
|
title={email.accountLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
"truncate text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
@@ -287,7 +309,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{email.subject || "(no subject)"}
|
{email.subject || "(no subject)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview && density !== 'extra-compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
isUnread
|
isUnread
|
||||||
@@ -305,7 +327,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -340,12 +362,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||||
|
|
||||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||||
|
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
|
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
@@ -355,6 +380,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)).slice(0, 4)
|
)).slice(0, 4)
|
||||||
: participantNames;
|
: participantNames;
|
||||||
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
|
|
||||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||||
email: latestEmail,
|
email: latestEmail,
|
||||||
@@ -375,7 +401,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
const threadColor = getThreadColorTag(thread.emails);
|
||||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||||
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
|
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
const isSelected = selectedEmailId === latestEmail.id ||
|
||||||
@@ -467,7 +493,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
isSelected && !colorTag && "shadow-sm",
|
isSelected && !colorTag && "shadow-sm",
|
||||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||||
|
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||||
isExpanded && "border-b border-border/50",
|
isExpanded && "border-b border-border/50",
|
||||||
@@ -541,6 +568,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
email={avatarPerson?.email}
|
email={avatarPerson?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -548,6 +576,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{isFocusedMailLayout ? (
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: threadAccountColor }}
|
||||||
|
title={latestEmail.accountLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
@@ -602,6 +637,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: threadAccountColor }}
|
||||||
|
title={latestEmail.accountLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
"truncate text-sm",
|
||||||
hasUnread
|
hasUnread
|
||||||
@@ -673,7 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{latestEmail.subject || "(no subject)"}
|
{latestEmail.subject || "(no subject)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview && density !== 'extra-compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
hasUnread
|
hasUnread
|
||||||
@@ -691,7 +733,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{/* Hover Quick Actions for thread header */}
|
{/* Hover Quick Actions for thread header */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={latestEmail}
|
email={latestEmail}
|
||||||
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export function FileBrowser({
|
|||||||
// Filter and sort resources
|
// Filter and sort resources
|
||||||
const displayResources = useMemo(() => {
|
const displayResources = useMemo(() => {
|
||||||
let filtered = resources;
|
let filtered = resources;
|
||||||
// In sidebar mode, folders are shown in the sidebar tree — hide them from the main list
|
// In sidebar mode, folders are shown in the sidebar tree - hide them from the main list
|
||||||
if (folderLayout === "sidebar") {
|
if (folderLayout === "sidebar") {
|
||||||
filtered = filtered.filter(r => !r.isDirectory);
|
filtered = filtered.filter(r => !r.isDirectory);
|
||||||
}
|
}
|
||||||
@@ -1466,7 +1466,7 @@ export function FileBrowser({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
||||||
{resource.isDirectory ? "—" : formatFileSize(resource.contentLength)}
|
{resource.isDirectory ? "-" : formatFileSize(resource.contentLength)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
||||||
{formatDate(resource.lastModified)}
|
{formatDate(resource.lastModified)}
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
sandbox="allow-same-origin"
|
sandbox="allow-scripts"
|
||||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||||
title={name}
|
title={name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't cache empty root results — empty root likely means client wasn't ready yet
|
// Don't cache empty root results - empty root likely means client wasn't ready yet
|
||||||
if (folders.length > 0 || parentId !== null) {
|
if (folders.length > 0 || parentId !== null) {
|
||||||
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
} from "@/lib/jmap/sieve-types";
|
} from "@/lib/jmap/sieve-types";
|
||||||
import type { Mailbox } from "@/lib/jmap/types";
|
import type { Mailbox } from "@/lib/jmap/types";
|
||||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface FilterRuleModalProps {
|
interface FilterRuleModalProps {
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
@@ -58,6 +59,7 @@ export function FilterRuleModal({
|
|||||||
}: FilterRuleModalProps) {
|
}: FilterRuleModalProps) {
|
||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
const isEdit = !!rule;
|
const isEdit = !!rule;
|
||||||
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
|
||||||
const [name, setName] = useState(rule?.name || "");
|
const [name, setName] = useState(rule?.name || "");
|
||||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||||
@@ -375,12 +377,17 @@ export function FilterRuleModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{action.type === "add_label" && (
|
{action.type === "add_label" && (
|
||||||
<Input
|
<select
|
||||||
value={action.value || ""}
|
value={action.value || ""}
|
||||||
onChange={(e) => updateAction(index, { value: e.target.value })}
|
onChange={(e) => updateAction(index, { value: e.target.value })}
|
||||||
placeholder={t("label_placeholder")}
|
className={`${selectClass} flex-1 min-w-[140px]`}
|
||||||
className="flex-1 min-w-[140px]"
|
aria-label={t("label_placeholder")}
|
||||||
/>
|
>
|
||||||
|
<option value="">{t("label_placeholder")}</option>
|
||||||
|
{emailKeywords.map((kw) => (
|
||||||
|
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuHeader,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
CheckCheck,
|
||||||
|
MailOpen,
|
||||||
|
Mails,
|
||||||
|
Trash2,
|
||||||
|
FolderPlus,
|
||||||
|
Pencil,
|
||||||
|
FolderX,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MailboxContextTarget =
|
||||||
|
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
|
||||||
|
| { kind: "folders-section" };
|
||||||
|
|
||||||
|
interface MailboxContextMenuProps {
|
||||||
|
target: MailboxContextTarget | null;
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onMarkFolderRead?: (mailboxId: string) => void;
|
||||||
|
onMarkFolderTreeRead?: (mailboxId: string) => void;
|
||||||
|
onMarkAllFoldersRead?: () => void;
|
||||||
|
onEmptyFolder?: (mailboxId: string) => void;
|
||||||
|
onCreateSubfolder?: (parentId: string) => void;
|
||||||
|
onCreateFolder?: () => void;
|
||||||
|
onRenameFolder?: (mailboxId: string) => void;
|
||||||
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailboxContextMenu({
|
||||||
|
target,
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onMarkFolderRead,
|
||||||
|
onMarkFolderTreeRead,
|
||||||
|
onMarkAllFoldersRead,
|
||||||
|
onEmptyFolder,
|
||||||
|
onCreateSubfolder,
|
||||||
|
onCreateFolder,
|
||||||
|
onRenameFolder,
|
||||||
|
onDeleteFolder,
|
||||||
|
onRefresh,
|
||||||
|
}: MailboxContextMenuProps) {
|
||||||
|
const t = useTranslations("mailbox_context_menu");
|
||||||
|
|
||||||
|
const handleAction = (action: () => void) => {
|
||||||
|
action();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
if (target.kind === "folders-section") {
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CheckCheck}
|
||||||
|
label={t("mark_all_folders_read")}
|
||||||
|
onClick={() => handleAction(onMarkAllFoldersRead!)}
|
||||||
|
disabled={!onMarkAllFoldersRead}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderPlus}
|
||||||
|
label={t("new_folder")}
|
||||||
|
onClick={() => handleAction(onCreateFolder!)}
|
||||||
|
disabled={!onCreateFolder}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={t("refresh")}
|
||||||
|
onClick={() => handleAction(onRefresh!)}
|
||||||
|
disabled={!onRefresh}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailbox = target.mailbox;
|
||||||
|
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
|
||||||
|
const isSystem =
|
||||||
|
!!mailbox.role &&
|
||||||
|
["inbox", "sent", "drafts", "trash", "junk", "archive"].includes(mailbox.role);
|
||||||
|
const canRename = mailbox.myRights?.mayRename !== false && !isSystem;
|
||||||
|
const canDelete = mailbox.myRights?.mayDelete !== false && !isSystem;
|
||||||
|
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
|
||||||
|
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||||
|
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={MailOpen}
|
||||||
|
label={t("mark_folder_read")}
|
||||||
|
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
|
||||||
|
disabled={!onMarkFolderRead || !canSetSeen}
|
||||||
|
/>
|
||||||
|
{target.hasChildren && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Mails}
|
||||||
|
label={t("mark_folder_tree_read")}
|
||||||
|
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
|
||||||
|
disabled={!onMarkFolderTreeRead || !canSetSeen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderPlus}
|
||||||
|
label={t("new_subfolder")}
|
||||||
|
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
|
||||||
|
disabled={!onCreateSubfolder || !canCreateChild}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("rename")}
|
||||||
|
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||||
|
disabled={!onRenameFolder || !canRename}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={FolderX}
|
||||||
|
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
|
||||||
|
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
|
||||||
|
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("delete_folder")}
|
||||||
|
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
|
||||||
|
disabled={!onDeleteFolder || !canDelete}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={t("refresh")}
|
||||||
|
onClick={() => handleAction(onRefresh!)}
|
||||||
|
disabled={!onRefresh}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
|
|||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { usePathname, Link } from "@/i18n/navigation";
|
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||||
import NextLink from "next/link";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -18,10 +17,11 @@ import { usePolicyStore } from "@/stores/policy-store";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||||
import { getInitials } from "@/lib/account-utils";
|
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
|
import { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -162,6 +162,7 @@ export function NavigationRail({
|
|||||||
}: NavigationRailProps) {
|
}: NavigationRailProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const { supportsCalendar } = useCalendarStore();
|
const { supportsCalendar } = useCalendarStore();
|
||||||
@@ -222,13 +223,14 @@ export function NavigationRail({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const headers = getActiveAccountSlotHeaders();
|
const headers = getActiveAccountSlotHeaders();
|
||||||
if (!headers['X-JMAP-Cookie-Slot']) return;
|
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||||
fetch('/api/admin/stalwart-check', { headers })
|
apiFetch('/api/admin/auth', { headers })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!cancelled && data.isStalwartAdmin) {
|
if (cancelled || !data.stalwartAdmin) return;
|
||||||
setIsStalwartAdmin(true);
|
setIsStalwartAdmin(true);
|
||||||
|
if (!data.authenticated) {
|
||||||
// Pre-create admin session so /admin works even after full page navigation
|
// Pre-create admin session so /admin works even after full page navigation
|
||||||
fetch('/api/admin/auth', {
|
apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...headers },
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
body: JSON.stringify({ stalwartAuth: true }),
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
@@ -274,7 +276,7 @@ export function NavigationRail({
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
isActive
|
isActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
@@ -293,7 +295,7 @@ export function NavigationRail({
|
|||||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-medium leading-tight">{t(item.labelKey)}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t(item.labelKey)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -315,7 +317,7 @@ export function NavigationRail({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
isActive
|
isActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
@@ -328,24 +330,24 @@ export function NavigationRail({
|
|||||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-medium leading-tight truncate max-w-[64px]">{app.name}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{app.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Admin (Stalwart admins) */}
|
{/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */}
|
||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<NextLink
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
"text-muted-foreground hover:text-foreground"
|
"text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Shield className="w-5 h-5" />
|
<Shield className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t("admin") || "Admin"}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||||
</NextLink>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
@@ -353,7 +355,7 @@ export function NavigationRail({
|
|||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
isSettingsActive
|
isSettingsActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
@@ -367,7 +369,7 @@ export function NavigationRail({
|
|||||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("settings")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
@@ -511,13 +513,13 @@ export function NavigationRail({
|
|||||||
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
|
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<NextLink
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
title={t("admin") || "Admin"}
|
title={t("admin") || "Admin"}
|
||||||
>
|
>
|
||||||
<Shield className="w-[18px] h-[18px]" />
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
</NextLink>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -604,6 +606,16 @@ export function NavigationRail({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{accounts.length < MAX_ACCOUNTS && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/login?mode=add-account` as never)}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
|
||||||
|
title={t("add_account")}
|
||||||
|
aria-label={t("add_account")}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout button with popover */}
|
{/* Logout button with popover */}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function SidebarAppForm({
|
|||||||
{t('icon_label')} <span className="text-destructive">*</span>
|
{t('icon_label')} <span className="text-destructive">*</span>
|
||||||
{SelectedIcon && (
|
{SelectedIcon && (
|
||||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||||
— <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
- <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+632
-358
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user