Initial release: JMAP Webmail Client
A modern, privacy-focused webmail client built with Next.js and the JMAP protocol. Designed for Stalwart Mail Server. Features: - Full email operations (compose, reply, forward, threading) - Real-time push notifications - Dark/light theme support - Mobile responsive design - Keyboard shortcuts - Drag-and-drop organization - i18n (English/French) - Security-first (external content blocked, HTML sanitization)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# JMAP Webmail Configuration
|
||||
# Copy this file to .env.local and fill in your values
|
||||
|
||||
# App name displayed in the UI
|
||||
NEXT_PUBLIC_APP_NAME=JMAP Webmail
|
||||
|
||||
# JMAP server URL (required)
|
||||
# This is the URL of your JMAP-compatible mail server
|
||||
NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# claude code
|
||||
.claude/
|
||||
@@ -0,0 +1 @@
|
||||
npx tsc --noEmit && npx eslint . --ext .ts,.tsx
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# Contributing to JMAP Webmail
|
||||
|
||||
Thank you for your interest in contributing to JMAP Webmail! This document provides guidelines and information for contributors.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Fork and clone** the repository:
|
||||
```bash
|
||||
git clone https://github.com/root-fr/jmap-webmail.git
|
||||
cd jmap-webmail
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up environment**:
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your JMAP server URL
|
||||
```
|
||||
|
||||
4. **Start development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
Before submitting a pull request, ensure your code passes all checks:
|
||||
|
||||
```bash
|
||||
# Type checking
|
||||
npm run typecheck
|
||||
|
||||
# Linting
|
||||
npm run lint
|
||||
|
||||
# Fix lint issues automatically
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Define proper types and interfaces
|
||||
- Avoid `any` types when possible
|
||||
- Use meaningful variable and function names
|
||||
|
||||
### React Components
|
||||
|
||||
- Use functional components with hooks
|
||||
- Keep components focused and single-purpose
|
||||
- Extract reusable logic into custom hooks
|
||||
- Place components in appropriate directories under `/components`
|
||||
|
||||
### Styling
|
||||
|
||||
- Use Tailwind CSS utility classes
|
||||
- Follow the existing design system
|
||||
- Support both dark and light themes
|
||||
- Use CSS variables for theme colors
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses **next-intl** for internationalization. Please follow these guidelines:
|
||||
|
||||
### Key Rules
|
||||
|
||||
1. **Never hardcode user-facing text** - Always use translations:
|
||||
```tsx
|
||||
const t = useTranslations('namespace');
|
||||
return <div>{t('key')}</div>;
|
||||
```
|
||||
|
||||
2. **Translation file locations**:
|
||||
- English: `/locales/en/common.json`
|
||||
- French: `/locales/fr/common.json`
|
||||
|
||||
3. **Namespace organization**:
|
||||
- `login.*` - Login page strings
|
||||
- `sidebar.*` - Sidebar navigation
|
||||
- `email_list.*` - Email list component
|
||||
- `email_viewer.*` - Email viewer component
|
||||
- `email_composer.*` - Email composer
|
||||
- `common.*` - Shared strings
|
||||
- `notifications.*` - Toast/alert messages
|
||||
- `settings.*` - Settings page
|
||||
|
||||
4. **Adding new strings**:
|
||||
- Add to **both** English and French translation files
|
||||
- Use descriptive, hierarchical keys
|
||||
- Keep translations consistent in tone
|
||||
|
||||
5. **Locale-aware navigation**:
|
||||
```tsx
|
||||
router.push(`/${params.locale}/settings`);
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Submitting
|
||||
|
||||
1. **Create a feature branch**:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
2. **Make your changes** following the code style guidelines
|
||||
|
||||
3. **Test your changes** thoroughly
|
||||
|
||||
4. **Update translations** if you added user-facing text
|
||||
|
||||
5. **Run all checks**:
|
||||
```bash
|
||||
npm run typecheck && npm run lint
|
||||
```
|
||||
|
||||
### Submitting
|
||||
|
||||
1. **Push your branch** to your fork
|
||||
|
||||
2. **Open a Pull Request** with:
|
||||
- Clear title describing the change
|
||||
- Description of what was changed and why
|
||||
- Screenshots for UI changes
|
||||
- Reference to any related issues
|
||||
|
||||
### Commit Message Convention
|
||||
|
||||
Follow the conventional commits format:
|
||||
|
||||
- `feat:` - New features
|
||||
- `fix:` - Bug fixes
|
||||
- `docs:` - Documentation changes
|
||||
- `style:` - Code style changes (formatting, etc.)
|
||||
- `refactor:` - Code refactoring
|
||||
- `test:` - Adding or updating tests
|
||||
- `chore:` - Maintenance tasks
|
||||
|
||||
Examples:
|
||||
```
|
||||
feat: add email threading support
|
||||
fix: resolve attachment download issue
|
||||
docs: update README with keyboard shortcuts
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
jmap-webmail/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ └── [locale]/ # Locale-aware routing
|
||||
├── components/ # React components
|
||||
│ ├── email/ # Email-related components
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── settings/ # Settings components
|
||||
│ └── ui/ # Reusable UI components
|
||||
├── contexts/ # React contexts
|
||||
├── hooks/ # Custom React hooks
|
||||
├── lib/ # Utilities and libraries
|
||||
│ └── jmap/ # JMAP client implementation
|
||||
├── locales/ # Translation files
|
||||
│ ├── en/ # English translations
|
||||
│ └── fr/ # French translations
|
||||
└── stores/ # Zustand state stores
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||
- **Sanitize user input** and email content
|
||||
- **Block external content** by default for privacy
|
||||
- Report security vulnerabilities privately
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to:
|
||||
- Open an issue for discussion
|
||||
- Check existing issues and pull requests
|
||||
|
||||
Thank you for helping improve JMAP Webmail!
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Matthieu MALVACHE
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,163 @@
|
||||
# JMAP Webmail
|
||||
|
||||
A modern, privacy-focused webmail client built with Next.js and the JMAP protocol.
|
||||
|
||||
## Built for Stalwart
|
||||
|
||||
This webmail client is designed to work seamlessly with [**Stalwart Mail Server**](https://stalw.art/) - a modern, secure, and blazingly fast mail server written in Rust.
|
||||
|
||||
**Why Stalwart?**
|
||||
- **Modern Architecture**: Built from the ground up with Rust for performance and safety
|
||||
- **JMAP-Native**: First-class support for the JMAP protocol (not just IMAP/SMTP bolted on)
|
||||
- **Privacy-Focused**: Self-hosted, no third-party dependencies, full control over your data
|
||||
- **Feature-Rich**: Supports JMAP, IMAP, SMTP, ManageSieve, and more
|
||||
|
||||
[Stalwart GitHub](https://github.com/stalwartlabs/mail-server) | [Documentation](https://stalw.art/docs/)
|
||||
|
||||
## Features
|
||||
|
||||
### Core Email Operations
|
||||
- Read, compose, reply, reply-all, and forward emails
|
||||
- Full HTML email rendering with security sanitization
|
||||
- Attachment upload and download
|
||||
- Draft auto-save with discard confirmation
|
||||
- Email threading with Gmail-style inline expansion
|
||||
- Mark as read/unread, star/unstar
|
||||
- Archive and delete with configurable behavior
|
||||
- Color tags/labels for email organization
|
||||
- Full-text search
|
||||
|
||||
### User Interface
|
||||
- Clean, minimalist three-pane layout
|
||||
- Dark and light theme support
|
||||
- Responsive design for mobile and desktop
|
||||
- Keyboard shortcuts for power users
|
||||
- Drag-and-drop email organization
|
||||
- Right-click context menus
|
||||
- Smooth animations and transitions
|
||||
- Infinite scroll pagination
|
||||
|
||||
### Real-time Updates
|
||||
- Push notifications via JMAP EventSource
|
||||
- Real-time unread counts
|
||||
- Live email arrival notifications
|
||||
- Connection status indicator
|
||||
|
||||
### Security & Privacy
|
||||
- External content blocked by default
|
||||
- HTML sanitization with DOMPurify
|
||||
- SPF/DKIM/DMARC status indicators
|
||||
- No password storage (session-based auth)
|
||||
- Shared folder support with proper permissions
|
||||
|
||||
### Internationalization
|
||||
- English and French language support
|
||||
- Automatic browser language detection
|
||||
- Persistent language preference
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [Next.js 16](https://nextjs.org/) with App Router
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/)
|
||||
- **State Management**: [Zustand](https://zustand-demo.pmnd.rs/)
|
||||
- **JMAP Client**: [jmap-jam](https://www.npmjs.com/package/jmap-jam)
|
||||
- **i18n**: [next-intl](https://next-intl-docs.vercel.app/)
|
||||
- **Icons**: [Lucide React](https://lucide.dev/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- A JMAP-compatible mail server (we recommend [Stalwart](https://stalw.art/))
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/root-fr/jmap-webmail.git
|
||||
cd jmap-webmail
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy environment configuration
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `.env.local` with your settings:
|
||||
|
||||
```env
|
||||
# App name displayed in the UI
|
||||
NEXT_PUBLIC_APP_NAME=My Webmail
|
||||
|
||||
# Your JMAP server URL
|
||||
NEXT_PUBLIC_JMAP_SERVER_URL=https://mail.example.com
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Type checking
|
||||
npm run typecheck
|
||||
|
||||
# Linting
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Start production server
|
||||
npm start
|
||||
```
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `j` / `k` | Navigate between emails |
|
||||
| `Enter` / `o` | Open selected email |
|
||||
| `Esc` | Close viewer / deselect |
|
||||
| `c` | Compose new email |
|
||||
| `r` | Reply |
|
||||
| `R` / `a` | Reply all |
|
||||
| `f` | Forward |
|
||||
| `s` | Toggle star |
|
||||
| `e` | Archive |
|
||||
| `#` / `Delete` | Delete |
|
||||
| `u` | Mark as unread |
|
||||
| `/` | Focus search |
|
||||
| `x` | Expand/collapse thread |
|
||||
| `?` | Show shortcuts help |
|
||||
|
||||
## Screenshots
|
||||
|
||||
*Coming soon*
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for planned features and development status.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [Stalwart Labs](https://stalw.art/) for creating an excellent JMAP mail server
|
||||
- The [JMAP](https://jmap.io/) working group for the protocol specification
|
||||
- All contributors and users of this project
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# JMAP Webmail - Roadmap
|
||||
|
||||
This document tracks the development status and planned features for JMAP Webmail.
|
||||
|
||||
## Completed Features
|
||||
|
||||
### Core Infrastructure
|
||||
- [x] Next.js 16 with TypeScript and App Router
|
||||
- [x] Tailwind CSS v4 with Oxide engine
|
||||
- [x] Zustand state management
|
||||
- [x] JMAP client implementation (jmap-jam)
|
||||
|
||||
### Authentication
|
||||
- [x] Login with JMAP server authentication
|
||||
- [x] Session management (no password storage for security)
|
||||
- [x] Username autocomplete with history
|
||||
- [x] Logout functionality
|
||||
- [x] Authentication error handling
|
||||
|
||||
### JMAP Server Connection
|
||||
- [x] Session establishment and keep-alive
|
||||
- [x] Connection error handling and retries
|
||||
- [x] Storage quota display
|
||||
- [x] Server capability detection
|
||||
- [x] Shared folders support (multi-account access)
|
||||
|
||||
### Email Operations
|
||||
- [x] Email fetching and display
|
||||
- [x] Full HTML email rendering
|
||||
- [x] Compose, reply, reply-all, forward
|
||||
- [x] Draft auto-save with discard confirmation
|
||||
- [x] Mark as read/unread
|
||||
- [x] Star/unstar emails
|
||||
- [x] Delete and archive
|
||||
- [x] Color tags/labels
|
||||
- [x] Full-text search
|
||||
- [x] Attachment upload and download
|
||||
- [x] Batch operations (multi-select)
|
||||
- [x] Quick reply form
|
||||
- [x] Email threading (Gmail-style inline expansion)
|
||||
|
||||
### Real-time Updates
|
||||
- [x] EventSource for JMAP push notifications
|
||||
- [x] State synchronization
|
||||
- [x] Email arrival notifications
|
||||
- [x] Real-time unread counts
|
||||
- [x] Mailbox change handling
|
||||
|
||||
### User Interface
|
||||
- [x] Three-pane layout (sidebar, list, viewer)
|
||||
- [x] Minimalist design system
|
||||
- [x] Dark and light theme support
|
||||
- [x] Custom scrollbars
|
||||
- [x] Mobile responsive design
|
||||
- [x] Keyboard shortcuts
|
||||
- [x] Drag-and-drop email organization
|
||||
- [x] Right-click context menus
|
||||
- [x] Hierarchical mailbox display
|
||||
- [x] Email list with avatars and visual hierarchy
|
||||
- [x] Expandable email headers
|
||||
- [x] External content warning banner
|
||||
- [x] SPF/DKIM/DMARC status indicators
|
||||
- [x] Loading states and skeletons
|
||||
- [x] Smooth transitions and animations
|
||||
- [x] Infinite scroll pagination
|
||||
- [x] Error boundaries
|
||||
- [x] Settings page with preferences
|
||||
|
||||
### Internationalization
|
||||
- [x] English language support
|
||||
- [x] French language support
|
||||
- [x] Automatic browser language detection
|
||||
- [x] Language preference persistence
|
||||
|
||||
### Security
|
||||
- [x] External content blocked by default
|
||||
- [x] HTML sanitization with DOMPurify
|
||||
- [x] User control for loading external content
|
||||
|
||||
## Planned Features
|
||||
|
||||
### Address Book & Contacts
|
||||
- [ ] Contact store with CRUD operations
|
||||
- [ ] Contacts list view with search/filter
|
||||
- [ ] Contact details view/edit form
|
||||
- [ ] Contact groups management
|
||||
- [ ] vCard import/export
|
||||
- [ ] JMAP contacts sync (if server supports)
|
||||
- [ ] Email autocomplete from contacts
|
||||
- [ ] Contacts integration in composer
|
||||
|
||||
### Advanced Features
|
||||
- [ ] Email filters and rules
|
||||
- [ ] Calendar integration (JMAP Calendars)
|
||||
- [ ] Email templates
|
||||
- [ ] Signature management
|
||||
- [ ] Vacation responder settings
|
||||
- [ ] Email aliases support
|
||||
- [ ] Advanced search with filters
|
||||
- [ ] Email encryption (PGP/GPG)
|
||||
|
||||
### Performance Optimizations
|
||||
- [ ] Virtual scrolling for large lists
|
||||
- [ ] Email content caching
|
||||
- [ ] Bundle size optimization
|
||||
- [ ] Service worker for offline support
|
||||
- [ ] Lazy loading for attachments
|
||||
|
||||
### Testing
|
||||
- [ ] Unit tests for utilities
|
||||
- [ ] Component tests
|
||||
- [ ] E2E tests with Playwright
|
||||
- [ ] Accessibility testing
|
||||
- [ ] Performance testing
|
||||
|
||||
### Deployment
|
||||
- [ ] Health check endpoint
|
||||
- [ ] Production build optimizations
|
||||
- [ ] Monitoring and logging
|
||||
|
||||
### Security Enhancements
|
||||
- [ ] CSP headers configuration
|
||||
- [ ] Additional XSS protection layers
|
||||
- [ ] Rate limiting
|
||||
- [ ] CORS configuration
|
||||
|
||||
## Known Issues
|
||||
|
||||
- [ ] Next.js workspace root warning (cosmetic)
|
||||
|
||||
## Contributing
|
||||
|
||||
Want to help implement a feature? Check out our [CONTRIBUTING.md](CONTRIBUTING.md) guide!
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, RefreshCw, Home } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for locale pages.
|
||||
* Catches errors in the locale layout and its children.
|
||||
*/
|
||||
export default function LocaleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
{t("page_error_title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t("page_error_description")}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => router.push(`/${params.locale}`)}>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
{t("go_home")}
|
||||
</Button>
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { locales } from "@/i18n/request";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!(locales as readonly string[]).includes(locale)) notFound();
|
||||
|
||||
// Load messages for the current locale
|
||||
let messages;
|
||||
try {
|
||||
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
const stored = localStorage.getItem('theme-storage');
|
||||
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const resolved = theme === 'system' ? systemTheme : theme;
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolved);
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('light');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { Mail, AlertCircle, Loader2, X } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations("login");
|
||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
|
||||
// All hooks must be called unconditionally at the top
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const justSelectedSuggestion = useRef(false);
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
if (serverUrl) {
|
||||
document.title = appName;
|
||||
}
|
||||
}, [appName, serverUrl]);
|
||||
|
||||
// Load saved usernames from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
if (saved) {
|
||||
try {
|
||||
const usernames = JSON.parse(saved);
|
||||
setSavedUsernames(usernames);
|
||||
} catch {
|
||||
console.error("Failed to parse saved usernames");
|
||||
}
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(`/${params.locale}`);
|
||||
}
|
||||
}, [isAuthenticated, router, params.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
}, [formData, clearError]);
|
||||
|
||||
// Filter suggestions based on input
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
// Skip showing suggestions if we just selected one
|
||||
if (justSelectedSuggestion.current) {
|
||||
justSelectedSuggestion.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.username && savedUsernames.length > 0) {
|
||||
const filtered = savedUsernames.filter(username =>
|
||||
username.toLowerCase().includes(formData.username.toLowerCase())
|
||||
);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
} else if (formData.username === "" && savedUsernames.length > 0) {
|
||||
setFilteredSuggestions(savedUsernames);
|
||||
setShowSuggestions(false); // Don't show on empty input
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}, [formData.username, savedUsernames, serverUrl]);
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) &&
|
||||
inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [serverUrl]);
|
||||
|
||||
// Show error if JMAP server URL is not configured
|
||||
if (!serverUrl) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div className="w-full max-w-sm mx-auto px-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
|
||||
<AlertCircle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-foreground mb-2">Configuration Error</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
NEXT_PUBLIC_JMAP_SERVER_URL environment variable is not set.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Save username on successful login
|
||||
const saveUsername = (username: string) => {
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
let usernames: string[] = [];
|
||||
|
||||
if (saved) {
|
||||
try {
|
||||
usernames = JSON.parse(saved);
|
||||
} catch {
|
||||
console.error("Failed to parse saved usernames");
|
||||
}
|
||||
}
|
||||
|
||||
// Add username if not already present, keep max 5 recent usernames
|
||||
if (!usernames.includes(username)) {
|
||||
usernames = [username, ...usernames].slice(0, 5);
|
||||
localStorage.setItem("webmail_usernames", JSON.stringify(usernames));
|
||||
setSavedUsernames(usernames);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a username from saved list
|
||||
const removeUsername = (username: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const updated = savedUsernames.filter(u => u !== username);
|
||||
localStorage.setItem("webmail_usernames", JSON.stringify(updated));
|
||||
setSavedUsernames(updated);
|
||||
setFilteredSuggestions(updated.filter(u =>
|
||||
u.toLowerCase().includes(formData.username.toLowerCase())
|
||||
));
|
||||
};
|
||||
|
||||
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, username: e.target.value });
|
||||
};
|
||||
|
||||
const handleUsernameFocus = () => {
|
||||
if (savedUsernames.length > 0 && formData.username === "") {
|
||||
setFilteredSuggestions(savedUsernames);
|
||||
setShowSuggestions(true);
|
||||
} else if (filteredSuggestions.length > 0) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectSuggestion = (username: string) => {
|
||||
justSelectedSuggestion.current = true;
|
||||
setFormData({ ...formData, username });
|
||||
setShowSuggestions(false);
|
||||
// Focus password field
|
||||
document.getElementById("password")?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!showSuggestions || filteredSuggestions.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedSuggestionIndex(prev =>
|
||||
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedSuggestionIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||
} else if (e.key === "Enter" && selectedSuggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
selectSuggestion(filteredSuggestions[selectedSuggestionIndex]);
|
||||
} else if (e.key === "Escape") {
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const success = await login(
|
||||
serverUrl,
|
||||
formData.username,
|
||||
formData.password
|
||||
);
|
||||
|
||||
if (success) {
|
||||
saveUsername(formData.username);
|
||||
router.push(`/${params.locale}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div className="w-full max-w-sm mx-auto px-4">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-6 shadow-lg shadow-primary/5">
|
||||
<Mail className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-light text-foreground tracking-tight">
|
||||
{appName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={handleUsernameChange}
|
||||
onFocus={handleUsernameFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
|
||||
placeholder={t("username_placeholder")}
|
||||
required
|
||||
autoComplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Custom autocomplete dropdown */}
|
||||
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute top-full mt-1 w-full bg-secondary border border-border rounded-md shadow-lg z-50 overflow-hidden"
|
||||
>
|
||||
{filteredSuggestions.map((username, index) => (
|
||||
<div
|
||||
key={username}
|
||||
className={`px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors ${
|
||||
index === selectedSuggestionIndex ? "bg-muted" : ""
|
||||
}`}
|
||||
onClick={() => selectSuggestion(username)}
|
||||
>
|
||||
<span className="text-sm text-foreground">{username}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => removeUsername(username, e)}
|
||||
className="p-1 hover:bg-background rounded transition-colors"
|
||||
title="Remove from history"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
|
||||
placeholder={t("password_placeholder")}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("signing_in")}
|
||||
</div>
|
||||
) : (
|
||||
t("sign_in")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
SidebarErrorFallback,
|
||||
EmailListErrorFallback,
|
||||
EmailViewerErrorFallback,
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations();
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
// Mobile conversation view state
|
||||
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
|
||||
// Mobile responsive hooks
|
||||
const { isMobile } = useDeviceDetection();
|
||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView } = useUIStore();
|
||||
const {
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedEmail,
|
||||
selectedMailbox,
|
||||
quota,
|
||||
isPushConnected,
|
||||
newEmailNotification,
|
||||
selectEmail,
|
||||
selectMailbox,
|
||||
selectAllEmails,
|
||||
clearSelection,
|
||||
fetchMailboxes,
|
||||
fetchEmails,
|
||||
fetchQuota,
|
||||
sendEmail,
|
||||
deleteEmail,
|
||||
markAsRead,
|
||||
toggleStar,
|
||||
moveToMailbox,
|
||||
searchEmails,
|
||||
isLoading,
|
||||
isLoadingEmail,
|
||||
setLoadingEmail,
|
||||
setPushConnected,
|
||||
handleStateChange,
|
||||
clearNewEmailNotification,
|
||||
} = useEmailStore();
|
||||
|
||||
// Play notification sound for new emails
|
||||
const playNotificationSound = () => {
|
||||
try {
|
||||
// Use Web Audio API for a simple notification beep
|
||||
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 800; // Hz
|
||||
oscillator.type = 'sine';
|
||||
gainNode.gain.value = 0.1; // Low volume
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.15); // Short beep
|
||||
} catch (e) {
|
||||
debug.log('Could not play notification sound:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handlers
|
||||
const keyboardHandlers = useMemo(() => ({
|
||||
onNextEmail: () => {
|
||||
if (emails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
if (nextIndex >= 0 && nextIndex < emails.length) {
|
||||
handleEmailSelect(emails[nextIndex]);
|
||||
}
|
||||
},
|
||||
onPreviousEmail: () => {
|
||||
if (emails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
||||
if (prevIndex >= 0 && prevIndex < emails.length) {
|
||||
handleEmailSelect(emails[prevIndex]);
|
||||
}
|
||||
},
|
||||
onOpenEmail: () => {
|
||||
// Email is already opened when selected
|
||||
},
|
||||
onCloseEmail: () => {
|
||||
selectEmail(null);
|
||||
if (isMobile) {
|
||||
setActiveView("list");
|
||||
}
|
||||
},
|
||||
onReply: () => {
|
||||
if (selectedEmail) handleReply();
|
||||
},
|
||||
onReplyAll: () => {
|
||||
if (selectedEmail) handleReplyAll();
|
||||
},
|
||||
onForward: () => {
|
||||
if (selectedEmail) handleForward();
|
||||
},
|
||||
onToggleStar: () => {
|
||||
if (selectedEmail) handleToggleStar();
|
||||
},
|
||||
onArchive: () => {
|
||||
if (selectedEmail) handleArchive();
|
||||
},
|
||||
onDelete: () => {
|
||||
if (selectedEmail) handleDelete();
|
||||
},
|
||||
onMarkAsUnread: async () => {
|
||||
if (selectedEmail && client) {
|
||||
await markAsRead(client, selectedEmail.id, false);
|
||||
}
|
||||
},
|
||||
onMarkAsRead: async () => {
|
||||
if (selectedEmail && client) {
|
||||
await markAsRead(client, selectedEmail.id, true);
|
||||
}
|
||||
},
|
||||
onCompose: () => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
},
|
||||
onFocusSearch: () => {
|
||||
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
},
|
||||
onShowHelp: () => {
|
||||
setShowShortcutsModal(true);
|
||||
},
|
||||
onRefresh: async () => {
|
||||
if (client && selectedMailbox) {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
},
|
||||
onSelectAll: () => {
|
||||
selectAllEmails();
|
||||
},
|
||||
onDeselectAll: () => {
|
||||
clearSelection();
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [emails, selectedEmail, client, selectedMailbox, isMobile]);
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
enabled: isAuthenticated && !showComposer,
|
||||
emails,
|
||||
selectedEmailId: selectedEmail?.id,
|
||||
handlers: keyboardHandlers,
|
||||
});
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = "Webmail";
|
||||
|
||||
if (showComposer) {
|
||||
// Composing email
|
||||
const modeText = {
|
||||
compose: t('email_composer.new_message'),
|
||||
reply: t('email_composer.reply'),
|
||||
replyAll: t('email_composer.reply_all'),
|
||||
forward: t('email_composer.forward'),
|
||||
}[composerMode] || t('email_composer.new_message');
|
||||
title = `${modeText} - Webmail`;
|
||||
} else if (selectedEmail) {
|
||||
// Reading email
|
||||
const subject = selectedEmail.subject || t('email_viewer.no_subject');
|
||||
title = `${subject} - Webmail`;
|
||||
} else if (selectedMailbox && mailboxes.length > 0) {
|
||||
// Mailbox view
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
if (mailbox) {
|
||||
const mailboxName = mailbox.name;
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
title = unreadCount > 0
|
||||
? `${mailboxName} (${unreadCount}) - Webmail`
|
||||
: `${mailboxName} - Webmail`;
|
||||
}
|
||||
}
|
||||
|
||||
document.title = title;
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
router.push(`/${params.locale}/login`);
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router, params.locale]);
|
||||
|
||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
||||
await Promise.all([
|
||||
fetchMailboxes(client),
|
||||
fetchQuota(client)
|
||||
]);
|
||||
|
||||
// Get the selected mailbox (should be inbox by default)
|
||||
const state = useEmailStore.getState();
|
||||
const selectedMailboxId = state.selectedMailbox;
|
||||
|
||||
// Fetch emails for the selected mailbox
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
}
|
||||
|
||||
// Setup push notifications after successful data load
|
||||
try {
|
||||
// Register state change callback
|
||||
client.onStateChange((change) => handleStateChange(change, client));
|
||||
|
||||
// Start receiving push notifications
|
||||
const pushEnabled = client.setupPushNotifications();
|
||||
|
||||
if (pushEnabled) {
|
||||
setPushConnected(true);
|
||||
debug.log('[Push] Push notifications successfully enabled');
|
||||
} else {
|
||||
debug.log('[Push] Push notifications not available on this server');
|
||||
}
|
||||
} catch (error) {
|
||||
// Push notifications are optional - don't break the app if they fail
|
||||
debug.log('[Push] Failed to setup push notifications:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading email data:', error);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
|
||||
// Cleanup push notifications on unmount
|
||||
return () => {
|
||||
if (client) {
|
||||
client.closePushNotifications();
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, handleStateChange, setPushConnected]);
|
||||
|
||||
// Handle mark-as-read with delay based on settings
|
||||
useEffect(() => {
|
||||
// Clear any existing timeout when email changes
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Clearing previous timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Only set timeout if there's a selected email, it's unread, and we have a client
|
||||
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current setting value
|
||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
||||
|
||||
if (markAsReadDelay === -1) {
|
||||
// Never mark as read automatically
|
||||
debug.log('[Mark as Read] Never mode - email will stay unread');
|
||||
} else if (markAsReadDelay === 0) {
|
||||
// Mark as read instantly
|
||||
debug.log('[Mark as Read] Instant mode - marking as read now');
|
||||
markAsRead(client, selectedEmail.id, true);
|
||||
} else {
|
||||
// Mark as read after delay
|
||||
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
||||
markAsReadTimeoutRef.current = setTimeout(() => {
|
||||
debug.log('[Mark as Read] Timeout fired - marking as read now');
|
||||
markAsRead(client, selectedEmail.id, true);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}, markAsReadDelay);
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when dependencies change
|
||||
return () => {
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id]);
|
||||
|
||||
// Handle new email notifications - play sound
|
||||
useEffect(() => {
|
||||
if (newEmailNotification) {
|
||||
playNotificationSound();
|
||||
debug.log('New email received:', newEmailNotification.subject);
|
||||
clearNewEmailNotification();
|
||||
}
|
||||
}, [newEmailNotification, clearNewEmailNotification]);
|
||||
|
||||
const handleEmailSend = async (data: {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
draftId?: string;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId);
|
||||
setShowComposer(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardDraft = async (draftId: string) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.deleteEmail(draftId);
|
||||
} catch (error) {
|
||||
console.error("Failed to discard draft:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleReplyAll = () => {
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
try {
|
||||
await deleteEmail(client, selectedEmail.id);
|
||||
selectEmail(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
// Find archive mailbox
|
||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||
if (archiveMailbox) {
|
||||
try {
|
||||
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
|
||||
selectEmail(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive email:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStar = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
try {
|
||||
await toggleStar(client, selectedEmail.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle star:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
// Remove any existing color tags
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const keywords = { ...email.keywords };
|
||||
|
||||
// Remove old color tags - set to false for JMAP to remove them
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$color:")) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Add new color tag if specified
|
||||
if (color) {
|
||||
keywords[`$color:${color}`] = true;
|
||||
}
|
||||
|
||||
// Update email keywords via JMAP
|
||||
await client.updateEmailKeywords(emailId, keywords);
|
||||
|
||||
// Update local state
|
||||
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
|
||||
|
||||
// Refresh emails list to show color in list
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
} catch (error) {
|
||||
console.error("Failed to set color tag:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMailboxSelect = async (mailboxId: string) => {
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null); // Clear selected email when switching mailboxes
|
||||
|
||||
// On mobile, close sidebar and go to list view
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView("list");
|
||||
}
|
||||
|
||||
if (client) {
|
||||
await fetchEmails(client, mailboxId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push(`/${params.locale}/login`);
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
await searchEmails(client, query);
|
||||
};
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.downloadBlob(blobId, name, type);
|
||||
} catch (error) {
|
||||
console.error("Failed to download attachment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickReply = async (body: string) => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
const sender = selectedEmail.from?.[0];
|
||||
if (!sender?.email) {
|
||||
throw new Error("No sender email found");
|
||||
}
|
||||
|
||||
// Send reply with just the body text
|
||||
await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||
body
|
||||
);
|
||||
|
||||
// Refresh emails to show the sent reply
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
};
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
const handleEmailSelect = async (email: { id: string }) => {
|
||||
if (!client || !email) return;
|
||||
|
||||
// Set loading state immediately (keep current email visible)
|
||||
setLoadingEmail(true);
|
||||
|
||||
// On mobile, switch to viewer
|
||||
if (isMobile) {
|
||||
setActiveView("viewer");
|
||||
}
|
||||
|
||||
// Fetch the full content
|
||||
try {
|
||||
// Find selected mailbox to determine accountId (for shared folders)
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
// Only pass accountId for shared mailboxes
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
const fullEmail = await client.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
selectEmail(fullEmail);
|
||||
// Mark-as-read logic is now handled by useEffect
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch email content:', error);
|
||||
} finally {
|
||||
setLoadingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle back navigation from viewer on mobile
|
||||
const handleMobileBack = () => {
|
||||
// If in conversation view, clear it
|
||||
if (conversationThread) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
}
|
||||
selectEmail(null);
|
||||
setActiveView("list");
|
||||
};
|
||||
|
||||
// Handle opening conversation view on mobile
|
||||
const handleOpenConversation = async (thread: ThreadGroup) => {
|
||||
if (!client) return;
|
||||
|
||||
setConversationThread(thread);
|
||||
setIsLoadingConversation(true);
|
||||
setActiveView("viewer");
|
||||
|
||||
try {
|
||||
// Fetch complete thread emails
|
||||
const emails = await client.getThreadEmails(thread.threadId);
|
||||
setConversationEmails(emails);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch thread emails:', error);
|
||||
// Fall back to thread.emails
|
||||
setConversationEmails(thread.emails);
|
||||
} finally {
|
||||
setIsLoadingConversation(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reply from conversation view
|
||||
const handleConversationReply = (email: Email) => {
|
||||
selectEmail(email);
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleConversationReplyAll = (email: Email) => {
|
||||
selectEmail(email);
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleConversationForward = (email: Email) => {
|
||||
selectEmail(email);
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
{/* Mobile Sidebar Overlay Backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar - overlay on mobile, fixed on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 h-full z-50",
|
||||
// Mobile: fixed overlay
|
||||
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:w-72",
|
||||
"max-md:transform max-md:transition-transform max-md:duration-300 max-md:ease-in-out",
|
||||
isMobile && !sidebarOpen && "max-md:-translate-x-full",
|
||||
// Desktop: normal flow
|
||||
"md:relative md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary fallback={SidebarErrorFallback}>
|
||||
<Sidebar
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
onSearch={handleSearch}
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-1 min-w-0 h-full">
|
||||
{/* Email List - full width on mobile, fixed width on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-background border-r border-border",
|
||||
// Mobile: full width, hidden when viewing email
|
||||
"max-md:flex-1 max-md:border-r-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
// Desktop: fixed width
|
||||
"md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm"
|
||||
)}
|
||||
>
|
||||
{/* Mobile Header for List View */}
|
||||
<MobileHeader
|
||||
title={currentMailboxName}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
emails={emails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={isLoading}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onOpenConversation={handleOpenConversation}
|
||||
// Context menu handlers
|
||||
onReply={(email) => {
|
||||
selectEmail(email);
|
||||
handleReply();
|
||||
}}
|
||||
onReplyAll={(email) => {
|
||||
selectEmail(email);
|
||||
handleReplyAll();
|
||||
}}
|
||||
onForward={(email) => {
|
||||
selectEmail(email);
|
||||
handleForward();
|
||||
}}
|
||||
onMarkAsRead={async (email, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, email.id, read);
|
||||
}
|
||||
}}
|
||||
onToggleStar={async (email) => {
|
||||
if (client) {
|
||||
await toggleStar(client, email.id);
|
||||
}
|
||||
}}
|
||||
onDelete={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleDelete();
|
||||
}}
|
||||
onArchive={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleArchive();
|
||||
}}
|
||||
onSetColorTag={(emailId, color) => {
|
||||
handleSetColorTag(emailId, color);
|
||||
}}
|
||||
onMoveToMailbox={async (emailId, mailboxId) => {
|
||||
if (client) {
|
||||
await moveToMailbox(client, emailId, mailboxId);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Email Viewer - full screen on mobile, flex on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-background",
|
||||
// Mobile: full screen overlay when active
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
// Desktop: flex grow
|
||||
"md:flex-1 md:relative"
|
||||
)}
|
||||
>
|
||||
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
||||
{isMobile && conversationThread ? (
|
||||
<ThreadConversationView
|
||||
thread={conversationThread}
|
||||
emails={conversationEmails}
|
||||
isLoading={isLoadingConversation}
|
||||
onBack={handleMobileBack}
|
||||
onReply={handleConversationReply}
|
||||
onReplyAll={handleConversationReplyAll}
|
||||
onForward={handleConversationForward}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile Header for Viewer */}
|
||||
{isMobile && activeView === "viewer" && (
|
||||
<MobileViewerHeader
|
||||
subject={selectedEmail?.subject}
|
||||
onBack={handleMobileBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
isLoading={isLoadingEmail}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
}
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
currentUserEmail={client?.["username"]}
|
||||
currentUserName={client?.["username"]?.split("@")[0]}
|
||||
className={isMobile ? "flex-1" : undefined}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Composer Modal */}
|
||||
{showComposer && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 md:p-0">
|
||||
<div className={cn(
|
||||
"w-full h-full md:h-auto md:max-w-3xl md:max-h-[600px]",
|
||||
"max-md:flex max-md:flex-col"
|
||||
)}>
|
||||
<ErrorBoundary
|
||||
fallback={ComposerErrorFallback}
|
||||
onReset={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
mode={composerMode}
|
||||
replyTo={selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
receivedAt: selectedEmail.receivedAt
|
||||
} : undefined}
|
||||
onSend={handleEmailSend}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
}}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
<KeyboardShortcutsModal
|
||||
isOpen={showShortcutsModal}
|
||||
onClose={() => setShowShortcutsModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||
import { EmailSettings } from '@/components/settings/email-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'advanced';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations('settings');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'appearance', label: t('tabs.appearance') },
|
||||
{ id: 'email', label: t('tabs.email') },
|
||||
{ id: 'account', label: t('tabs.account') },
|
||||
{ id: 'advanced', label: t('tabs.advanced') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Settings Sidebar */}
|
||||
<div className="w-64 border-r border-border bg-secondary flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/${params.locale}`)}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t('back_to_mail')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 space-y-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded text-sm transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto p-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<SettingsIcon className="w-8 h-8 text-foreground" />
|
||||
<h1 className="text-3xl font-semibold text-foreground">{t('title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Tab Content */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||
{activeTab === 'email' && <EmailSettings />}
|
||||
{activeTab === 'account' && <AccountSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Global error boundary for the root layout.
|
||||
* Note: This component cannot use translations since it's outside providers.
|
||||
* It must render its own <html> and <body> tags as it replaces the root layout.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Global error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-gray-50 dark:bg-gray-900">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<AlertTriangle className="w-10 h-10 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
An unexpected error occurred. Please try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #94a3b8;
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0f172a;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #f8fafc;
|
||||
--color-secondary-foreground: #0f172a;
|
||||
--color-muted: #f1f5f9;
|
||||
--color-muted-foreground: #64748b;
|
||||
--color-accent: #dbeafe;
|
||||
--color-accent-foreground: #1e40af;
|
||||
|
||||
/* Settings variables */
|
||||
--font-size-base: 16px;
|
||||
--list-item-height: 48px;
|
||||
--transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-border: #262626;
|
||||
--color-input: #262626;
|
||||
--color-ring: #d4d4d4;
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
--color-primary: #fafafa;
|
||||
--color-primary-foreground: #171717;
|
||||
--color-secondary: #262626;
|
||||
--color-secondary-foreground: #fafafa;
|
||||
--color-muted: #262626;
|
||||
--color-muted-foreground: #a3a3a3;
|
||||
--color-accent: #1e3a8a;
|
||||
--color-accent-foreground: #dbeafe;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-border: var(--color-border);
|
||||
--color-input: var(--color-input);
|
||||
--color-ring: var(--color-ring);
|
||||
--color-background: var(--color-background);
|
||||
--color-foreground: var(--color-foreground);
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-foreground: var(--color-primary-foreground);
|
||||
--color-secondary: var(--color-secondary);
|
||||
--color-secondary-foreground: var(--color-secondary-foreground);
|
||||
--color-muted: var(--color-muted);
|
||||
--color-muted-foreground: var(--color-muted-foreground);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-accent-foreground: var(--color-accent-foreground);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: var(--font-size-base);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
/* Minimalist scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* Enhanced Email Content Styling */
|
||||
.email-content {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.email-content p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.email-content a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.email-content a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color-accent-foreground);
|
||||
}
|
||||
|
||||
.email-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.375rem;
|
||||
margin: 1rem 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.email-content blockquote {
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dark .email-content blockquote {
|
||||
border-left-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.email-content pre {
|
||||
background-color: #f5f5f7;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.dark .email-content pre {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.email-content code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.dark .email-content code {
|
||||
background-color: #374151;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.email-content h1,
|
||||
.email-content h2,
|
||||
.email-content h3,
|
||||
.email-content h4 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.email-content h1 { font-size: 1.5rem; }
|
||||
.email-content h2 { font-size: 1.25rem; }
|
||||
.email-content h3 { font-size: 1.125rem; }
|
||||
.email-content h4 { font-size: 1rem; }
|
||||
|
||||
.email-content ul,
|
||||
.email-content ol {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.email-content li {
|
||||
margin: 0.375rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.email-content ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.email-content ol li {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* Only style tables that are actual data tables, not layout tables */
|
||||
.email-content table.data-table,
|
||||
.email-content table[border="1"] {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.email-content table.data-table th,
|
||||
.email-content table.data-table td,
|
||||
.email-content table[border="1"] th,
|
||||
.email-content table[border="1"] td {
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.email-content table.data-table th,
|
||||
.email-content table[border="1"] th {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .email-content table.data-table th,
|
||||
.dark .email-content table[border="1"] th {
|
||||
background-color: #1f2937;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.dark .email-content table.data-table td,
|
||||
.dark .email-content table[border="1"] td {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
/* Reset styles for layout tables (commonly used in HTML emails) */
|
||||
.email-content table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* Let HTML email's own styles take precedence */
|
||||
.email-content table:not(.data-table):not([border="1"]) {
|
||||
border: initial;
|
||||
}
|
||||
|
||||
.email-content table:not(.data-table):not([border="1"]) td,
|
||||
.email-content table:not(.data-table):not([border="1"]) th {
|
||||
border: initial;
|
||||
padding: initial;
|
||||
}
|
||||
|
||||
.email-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.dark .email-content hr {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
/* Email thread quoted text */
|
||||
.email-content .quoted-text {
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #6b7280;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dark .email-content .quoted-text {
|
||||
border-left-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Utilities */
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||
.safe-area-inset-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-inset-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-area-inset-left {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-area-inset-right {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Minimum touch targets (44x44px recommended by Apple HIG) */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Prevent text selection on mobile for UI elements */
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* Smooth transitions for view switching */
|
||||
.view-transition {
|
||||
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Hide scrollbar on mobile while keeping functionality */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-scroll-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-scroll-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile backdrop blur support */
|
||||
@supports (backdrop-filter: blur(8px)) {
|
||||
.mobile-backdrop {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent overscroll bounce on iOS */
|
||||
.no-overscroll {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Slide in from right animation (for mobile views) */
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-from-right {
|
||||
animation: slide-in-from-right 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Slide in from left animation (for sidebar) */
|
||||
@keyframes slide-in-from-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-from-left {
|
||||
animation: slide-in-from-left 0.3s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// This is the root layout that wraps all pages
|
||||
// The actual layout with providers and styles is in [locale]/layout.tsx
|
||||
export default function RootLayout({ children }: Props) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
interface EmailComposerProps {
|
||||
onSend?: (data: {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
draftId?: string;
|
||||
}) => void;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
className?: string;
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: {
|
||||
from?: { email?: string; name?: string }[];
|
||||
to?: { email?: string; name?: string }[];
|
||||
cc?: { email?: string; name?: string }[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
receivedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onClose,
|
||||
onDiscardDraft,
|
||||
className,
|
||||
mode = 'compose',
|
||||
replyTo
|
||||
}: EmailComposerProps) {
|
||||
const t = useTranslations('email_composer');
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
if (!replyTo) return "";
|
||||
if (mode === 'reply') {
|
||||
return replyTo.from?.[0]?.email || "";
|
||||
} else if (mode === 'replyAll') {
|
||||
const from = replyTo.from?.[0]?.email || "";
|
||||
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
||||
return [from, originalTo].filter(Boolean).join(", ");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getInitialCc = () => {
|
||||
if (!replyTo || mode !== 'replyAll') return "";
|
||||
return replyTo.cc?.map(r => r.email).join(", ") || "";
|
||||
};
|
||||
|
||||
const getInitialSubject = () => {
|
||||
if (!replyTo?.subject) return "";
|
||||
if (mode === 'forward') {
|
||||
return `Fwd: ${replyTo.subject.replace(/^(Fwd:\s*)+/i, '')}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `Re: ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getInitialBody = () => {
|
||||
if (!replyTo?.body) return "";
|
||||
|
||||
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : "Unknown";
|
||||
|
||||
if (mode === 'forward') {
|
||||
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const [to, setTo] = useState(getInitialTo());
|
||||
const [cc, setCc] = useState(getInitialCc());
|
||||
const [bcc, setBcc] = useState("");
|
||||
const [subject, setSubject] = useState(getInitialSubject());
|
||||
const [body, setBody] = useState(getInitialBody());
|
||||
const [showCc, setShowCc] = useState(!!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(false);
|
||||
const [draftId, setDraftId] = useState<string | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedDataRef = useRef<string>("");
|
||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { client } = useAuthStore();
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!client || !event.target.files) return;
|
||||
|
||||
const files = Array.from(event.target.files);
|
||||
|
||||
// Add files to attachments list with uploading state
|
||||
const newAttachments = files.map(file => ({ file, uploading: true }));
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
|
||||
// Upload each file
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
try {
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
|
||||
// Update attachment with blobId
|
||||
setAttachments(prev =>
|
||||
prev.map(att =>
|
||||
att.file === file
|
||||
? { ...att, blobId, uploading: false }
|
||||
: att
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to upload ${file.name}:`, error);
|
||||
|
||||
// Mark attachment as failed
|
||||
setAttachments(prev =>
|
||||
prev.map(att =>
|
||||
att.file === file
|
||||
? { ...att, uploading: false, error: true }
|
||||
: att
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Remove attachment
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Auto-save draft functionality
|
||||
const saveDraft = async (): Promise<string | null> => {
|
||||
if (!client) return null;
|
||||
|
||||
const toAddresses = to.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);
|
||||
|
||||
// Only save if there's some content
|
||||
if (!toAddresses.length && !subject && !body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prepare attachments for draft
|
||||
const uploadedAttachments = attachments
|
||||
.filter(att => att.blobId && !att.uploading)
|
||||
.map(att => ({
|
||||
blobId: att.blobId!,
|
||||
name: att.file.name,
|
||||
type: att.file.type,
|
||||
size: att.file.size,
|
||||
}));
|
||||
|
||||
// Create a hash of current data to compare with last saved
|
||||
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments });
|
||||
|
||||
// Only save if data has changed
|
||||
if (currentData === lastSavedDataRef.current) {
|
||||
return draftId;
|
||||
}
|
||||
|
||||
setSaveStatus('saving');
|
||||
|
||||
try {
|
||||
const savedDraftId = await client.createDraft(
|
||||
toAddresses,
|
||||
subject || "(No subject)",
|
||||
body,
|
||||
ccAddresses,
|
||||
bccAddresses,
|
||||
draftId || undefined,
|
||||
uploadedAttachments
|
||||
);
|
||||
|
||||
setDraftId(savedDraftId);
|
||||
lastSavedDataRef.current = currentData;
|
||||
setSaveStatus('saved');
|
||||
|
||||
// Reset status after 2 seconds
|
||||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
|
||||
return savedDraftId;
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft:', error);
|
||||
setSaveStatus('error');
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger auto-save when content changes
|
||||
useEffect(() => {
|
||||
// Clear existing timeout
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Don't auto-save if there's no content
|
||||
if (!to && !subject && !body) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set new timeout for auto-save (2 seconds after last change)
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveDraft();
|
||||
}, 2000);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
|
||||
}, [to, cc, bcc, subject, body, attachments]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const toAddresses = to.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);
|
||||
|
||||
// Allow sending if we have recipient, subject, and either body text or attachments
|
||||
const hasContent = body || attachments.some(att => att.blobId && !att.uploading);
|
||||
|
||||
if (toAddresses.length > 0 && subject && hasContent) {
|
||||
// Wait for any pending auto-save to complete and get the latest draft ID
|
||||
let finalDraftId = draftId;
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
// saveDraft returns the new draft ID after destroy+create
|
||||
const savedId = await saveDraft();
|
||||
if (savedId) {
|
||||
finalDraftId = savedId;
|
||||
}
|
||||
}
|
||||
|
||||
onSend?.({
|
||||
to: toAddresses,
|
||||
cc: ccAddresses,
|
||||
bcc: bccAddresses,
|
||||
subject,
|
||||
body,
|
||||
draftId: finalDraftId || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setTo("");
|
||||
setCc("");
|
||||
setBcc("");
|
||||
setSubject("");
|
||||
setBody("");
|
||||
setDraftId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// If there's a draft with content, ask user if they want to discard
|
||||
if (draftId && (to || subject || body)) {
|
||||
const confirmDiscard = window.confirm(t('discard_draft_confirm'));
|
||||
|
||||
if (confirmDiscard) {
|
||||
// Clear any pending auto-save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Delete the draft if callback is provided
|
||||
if (onDiscardDraft) {
|
||||
onDiscardDraft(draftId);
|
||||
}
|
||||
|
||||
onClose?.();
|
||||
}
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background border rounded-lg", className)}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">New Message</h3>
|
||||
{saveStatus === 'saving' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Save className="w-3 h-3 animate-pulse" />
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600">
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Draft saved</span>
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<div className="flex items-center gap-1 text-xs text-red-600">
|
||||
<X className="w-3 h-3" />
|
||||
<span>Failed to save</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="space-y-2 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-16">To:</span>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Recipient email addresses (comma separated)"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCc(!showCc)}
|
||||
className="text-xs"
|
||||
>
|
||||
Cc
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowBcc(!showBcc)}
|
||||
className="text-xs"
|
||||
>
|
||||
Bcc
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCc && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-16">Cc:</span>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Cc recipients (comma separated)"
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBcc && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-16">Bcc:</span>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Bcc recipients (comma separated)"
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground w-16">Subject:</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 py-3">
|
||||
<textarea
|
||||
className="w-full h-full resize-none outline-none text-sm"
|
||||
placeholder="Compose email..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attachments display */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-4 py-2 border-t">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachments.map((att, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1 rounded-md text-sm",
|
||||
att.error ? "bg-red-50 text-red-700" : "bg-gray-100 text-gray-700"
|
||||
)}
|
||||
>
|
||||
{att.uploading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : att.error ? (
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<Paperclip className="w-3 h-3" />
|
||||
)}
|
||||
<span className="max-w-[200px] truncate">{att.file.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
({(att.file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeAttachment(index)}
|
||||
className="ml-1 hover:text-red-600"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
accept="*/*"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Paperclip className="w-4 h-4 mr-2" />
|
||||
Attach
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSend}>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Email, Mailbox } from "@/lib/jmap/types";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSubMenu,
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Mail,
|
||||
MailOpen,
|
||||
Star,
|
||||
Trash2,
|
||||
Archive,
|
||||
FolderInput,
|
||||
Palette,
|
||||
X,
|
||||
Inbox,
|
||||
Send,
|
||||
File,
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface EmailContextMenuProps {
|
||||
email: Email;
|
||||
position: Position;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
mailboxes: Mailbox[];
|
||||
selectedMailbox: string;
|
||||
isMultiSelect?: boolean;
|
||||
selectedCount?: number;
|
||||
// Single email actions
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onToggleStar?: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
// Batch actions
|
||||
onBatchMarkAsRead?: (read: boolean) => void;
|
||||
onBatchDelete?: () => void;
|
||||
onBatchMoveToMailbox?: (mailboxId: string) => void;
|
||||
}
|
||||
|
||||
// Color options for email tags
|
||||
const colorOptions = [
|
||||
{ name: "Red", value: "red", color: "bg-red-500" },
|
||||
{ name: "Orange", value: "orange", color: "bg-orange-500" },
|
||||
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
|
||||
{ name: "Green", value: "green", color: "bg-green-500" },
|
||||
{ name: "Blue", value: "blue", color: "bg-blue-500" },
|
||||
{ name: "Purple", value: "purple", color: "bg-purple-500" },
|
||||
{ name: "Pink", value: "pink", color: "bg-pink-500" },
|
||||
];
|
||||
|
||||
// Get mailbox icon based on role
|
||||
const getMailboxIcon = (role?: string) => {
|
||||
switch (role) {
|
||||
case "inbox":
|
||||
return Inbox;
|
||||
case "sent":
|
||||
return Send;
|
||||
case "drafts":
|
||||
return File;
|
||||
case "trash":
|
||||
return Trash2;
|
||||
case "archive":
|
||||
return Archive;
|
||||
default:
|
||||
return Folder;
|
||||
}
|
||||
};
|
||||
|
||||
// Get current color from email keywords
|
||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
||||
return key.replace("$color:", "");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function EmailContextMenu({
|
||||
email,
|
||||
position,
|
||||
isOpen,
|
||||
onClose,
|
||||
menuRef,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
isMultiSelect = false,
|
||||
selectedCount = 1,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onMoveToMailbox,
|
||||
onBatchMarkAsRead,
|
||||
onBatchDelete,
|
||||
onBatchMoveToMailbox,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const currentColor = getCurrentColor(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
|
||||
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
|
||||
const moveTargets = mailboxes.filter(
|
||||
(m) =>
|
||||
m.id !== selectedMailbox &&
|
||||
m.role !== "drafts" &&
|
||||
!m.id.startsWith("shared-") &&
|
||||
m.myRights?.mayAddItems
|
||||
);
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
isOpen={isOpen}
|
||||
position={position}
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* Batch header */}
|
||||
{showBatchActions && (
|
||||
<ContextMenuHeader>
|
||||
{t("items_selected", { count: selectedCount })}
|
||||
</ContextMenuHeader>
|
||||
)}
|
||||
|
||||
{/* Single email actions - Reply, Reply All, Forward */}
|
||||
{!showBatchActions && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={Reply}
|
||||
label={t("reply")}
|
||||
onClick={() => handleAction(onReply!)}
|
||||
disabled={!onReply}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={ReplyAll}
|
||||
label={t("reply_all")}
|
||||
onClick={() => handleAction(onReplyAll!)}
|
||||
disabled={!onReplyAll}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Forward}
|
||||
label={t("forward")}
|
||||
onClick={() => handleAction(onForward!)}
|
||||
disabled={!onForward}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mark as read/unread */}
|
||||
<ContextMenuItem
|
||||
icon={isUnread ? MailOpen : Mail}
|
||||
label={isUnread ? t("mark_read") : t("mark_unread")}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
? onBatchMarkAsRead?.(isUnread)
|
||||
: onMarkAsRead?.(isUnread)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Star/Unstar - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuItem
|
||||
icon={Star}
|
||||
label={isStarred ? t("unstar") : t("star")}
|
||||
onClick={() => handleAction(onToggleStar!)}
|
||||
disabled={!onToggleStar}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{/* Move to submenu */}
|
||||
{moveTargets.length > 0 && (
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
|
||||
{moveTargets.map((mailbox) => {
|
||||
const Icon = getMailboxIcon(mailbox.role);
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={mailbox.id}
|
||||
icon={Icon}
|
||||
label={mailbox.name}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
? onBatchMoveToMailbox?.(mailbox.id)
|
||||
: onMoveToMailbox?.(mailbox.id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
{/* Archive */}
|
||||
<ContextMenuItem
|
||||
icon={Archive}
|
||||
label={t("archive")}
|
||||
onClick={() => handleAction(onArchive!)}
|
||||
disabled={!onArchive}
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{/* Set color submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
|
||||
<div className="px-3 py-2 flex flex-wrap gap-1.5">
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
handleAction(() => onSetColorTag?.(option.value))
|
||||
}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full hover:scale-110 transition-transform",
|
||||
option.color,
|
||||
currentColor === option.value &&
|
||||
"ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
title={option.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{currentColor && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={X}
|
||||
label={t("remove_color")}
|
||||
onClick={() => handleAction(() => onSetColorTag?.(null))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{/* Delete */}
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("delete")}
|
||||
onClick={() =>
|
||||
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
||||
}
|
||||
disabled={showBatchActions ? !onBatchDelete : !onDelete}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
|
||||
interface EmailListItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
}
|
||||
|
||||
// Color tag mapping - using lighter backgrounds for better readability
|
||||
const colorTags = {
|
||||
red: "bg-red-50 dark:bg-red-950/30",
|
||||
orange: "bg-orange-50 dark:bg-orange-950/30",
|
||||
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
|
||||
green: "bg-green-50 dark:bg-green-950/30",
|
||||
blue: "bg-blue-50 dark:bg-blue-950/30",
|
||||
purple: "bg-purple-50 dark:bg-purple-950/30",
|
||||
pink: "bg-pink-50 dark:bg-pink-950/30",
|
||||
} as const;
|
||||
|
||||
const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
||||
const color = key.replace("$color:", "");
|
||||
return colorTags[color as keyof typeof colorTags] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
||||
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const sender = email.from?.[0];
|
||||
const colorTag = getEmailColor(email.keywords);
|
||||
|
||||
// Drag and drop functionality
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleEmailSelection(email.id);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...dragHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
// Apply color tag as background, with selected and unread states
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !colorTag && "bg-accent/30",
|
||||
// Add visual feedback for checked state
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
// Drag state visual feedback
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4" style={{
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{/* Checkbox with smooth animation */}
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
className={cn(
|
||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
isChecked && "text-primary"
|
||||
)}
|
||||
>
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded font-medium">
|
||||
Important
|
||||
</span>
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { ThreadListItem } from "./thread-list-item";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
// Mobile conversation view handler
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
// Context menu actions
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
onForward?: (email: Email) => void;
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
||||
}
|
||||
|
||||
export function EmailList({
|
||||
emails,
|
||||
selectedEmailId,
|
||||
onEmailSelect,
|
||||
className,
|
||||
isLoading = false,
|
||||
onOpenConversation,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onMoveToMailbox,
|
||||
}: EmailListProps) {
|
||||
const { client } = useAuthStore();
|
||||
const {
|
||||
selectedEmailIds,
|
||||
selectAllEmails,
|
||||
clearSelection,
|
||||
batchMarkAsRead,
|
||||
batchDelete,
|
||||
batchMoveToMailbox,
|
||||
loadMoreEmails,
|
||||
hasMoreEmails,
|
||||
isLoadingMore,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
expandedThreadIds,
|
||||
threadEmailsCache,
|
||||
isLoadingThread,
|
||||
toggleThreadExpansion,
|
||||
fetchThreadEmails,
|
||||
} = useEmailStore();
|
||||
|
||||
// Group emails by thread
|
||||
const threadGroups = useMemo(() => {
|
||||
const groups = groupEmailsByThread(emails);
|
||||
return sortThreadGroups(groups);
|
||||
}, [emails]);
|
||||
|
||||
// Context menu state
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
// Loading skeleton component - gentler, no pulsing
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="animate-in fade-in duration-200">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="border-b border-border px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-muted/50 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="h-4 bg-muted/50 rounded w-32" />
|
||||
<div className="h-3 bg-muted/50 rounded w-16" />
|
||||
</div>
|
||||
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
|
||||
<div className="h-3 bg-muted/50 rounded w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const hasSelection = selectedEmailIds.size > 0;
|
||||
const allSelected = emails.length > 0 && emails.every(e => selectedEmailIds.has(e.id));
|
||||
|
||||
const handleBatchMarkAsRead = async (read: boolean) => {
|
||||
if (!client || isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await batchMarkAsRead(client, read);
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 500); // Small delay for visual feedback
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (!client || isProcessing || !confirm(`Delete ${selectedEmailIds.size} emails?`)) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await batchDelete(client);
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||
loadMoreEmails(client);
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
||||
|
||||
// Handle thread expansion and fetch complete thread
|
||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||
const isExpanded = expandedThreadIds.has(threadId);
|
||||
|
||||
if (!isExpanded && client) {
|
||||
// Expanding - fetch complete thread emails
|
||||
toggleThreadExpansion(threadId);
|
||||
await fetchThreadEmails(client, threadId);
|
||||
} else {
|
||||
// Collapsing - just toggle
|
||||
toggleThreadExpansion(threadId);
|
||||
}
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
handleLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const currentTarget = observerTarget.current;
|
||||
if (currentTarget) {
|
||||
observer.observe(currentTarget);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentTarget) {
|
||||
observer.unobserve(currentTarget);
|
||||
}
|
||||
};
|
||||
}, [handleLoadMore]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Batch Actions Toolbar with smooth transition */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBatchMarkAsRead(true)}
|
||||
title="Mark as read"
|
||||
disabled={isProcessing}
|
||||
className="hover:bg-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<MailOpen className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleBatchMarkAsRead(false)}
|
||||
title="Mark as unread"
|
||||
disabled={isProcessing}
|
||||
className="hover:bg-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBatchDelete}
|
||||
title="Delete"
|
||||
disabled={isProcessing}
|
||||
className="text-red-600 dark:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
title="Clear selection"
|
||||
disabled={isProcessing}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Header */}
|
||||
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => allSelected ? clearSelection() : selectAllEmails()}
|
||||
className={cn(
|
||||
"p-1 rounded transition-all duration-200",
|
||||
"hover:bg-muted hover:scale-110",
|
||||
"active:scale-95",
|
||||
allSelected && "text-primary"
|
||||
)}
|
||||
title={allSelected ? "Deselect all" : "Select all"}
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<h2 className="text-sm font-medium text-foreground">
|
||||
{isLoading ? 'Loading...' : threadGroups.length > 0 ? `${threadGroups.length} conversations` : 'No conversations'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email List */}
|
||||
<div className="flex-1 overflow-y-auto bg-background relative">
|
||||
{/* Loading overlay - shows on top of existing emails */}
|
||||
{isLoading && emails.length > 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show skeleton only on initial load (no emails yet) */}
|
||||
{isLoading && emails.length === 0 ? (
|
||||
<LoadingSkeleton />
|
||||
) : emails.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12">
|
||||
<Inbox className="w-16 h-16 mb-4 text-muted-foreground/50" />
|
||||
<p className="text-base font-medium text-foreground">No emails in this mailbox</p>
|
||||
<p className="text-sm mt-1 text-muted-foreground">New messages will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
|
||||
{threadGroups.map((thread) => (
|
||||
<ThreadListItem
|
||||
key={thread.threadId}
|
||||
thread={thread}
|
||||
isExpanded={expandedThreadIds.has(thread.threadId)}
|
||||
selectedEmailId={selectedEmailId}
|
||||
isLoading={isLoadingThread === thread.threadId}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Intersection observer target for infinite scroll - always present */}
|
||||
<div ref={observerTarget} className="py-4 flex justify-center">
|
||||
{isLoadingMore && hasMoreEmails && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Loading more emails...</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasMoreEmails && emails.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground border-t border-border pt-6">
|
||||
No more emails to load
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.data && (
|
||||
<EmailContextMenu
|
||||
email={contextMenu.data}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onClose={closeContextMenu}
|
||||
menuRef={menuRef}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||
selectedCount={selectedEmailIds.size}
|
||||
// Single email actions
|
||||
onReply={() => onReply?.(contextMenu.data!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
||||
onForward={() => onForward?.(contextMenu.data!)}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
||||
// Batch actions
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatFileSize, cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Paperclip,
|
||||
Star,
|
||||
Download,
|
||||
Loader2,
|
||||
FileText,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
File,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface ThreadConversationViewProps {
|
||||
thread: ThreadGroup;
|
||||
emails: Email[];
|
||||
isLoading?: boolean;
|
||||
onBack: () => void;
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
onForward?: (email: Email) => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
|
||||
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
||||
}
|
||||
|
||||
// Helper function to get file icon based on mime type or extension
|
||||
const getFileIcon = (name?: string, type?: string) => {
|
||||
const ext = name?.split('.').pop()?.toLowerCase();
|
||||
const mimeType = type?.toLowerCase();
|
||||
|
||||
if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
|
||||
return FileImage;
|
||||
}
|
||||
if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) {
|
||||
return FileVideo;
|
||||
}
|
||||
if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) {
|
||||
return FileAudio;
|
||||
}
|
||||
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
||||
return FileText;
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
|
||||
return FileArchive;
|
||||
}
|
||||
return File;
|
||||
};
|
||||
|
||||
export function ThreadConversationView({
|
||||
thread,
|
||||
emails,
|
||||
isLoading = false,
|
||||
onBack,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onDownloadAttachment,
|
||||
onMarkAsRead,
|
||||
}: ThreadConversationViewProps) {
|
||||
const t = useTranslations();
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
|
||||
// Track which emails are expanded (most recent by default)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [allowExternalContent, setAllowExternalContent] = useState<Set<string>>(new Set());
|
||||
|
||||
// Auto-expand most recent email AND all unread emails when thread opens
|
||||
useEffect(() => {
|
||||
if (emails.length > 0) {
|
||||
const idsToExpand = new Set<string>();
|
||||
|
||||
// Always expand most recent
|
||||
idsToExpand.add(emails[0].id);
|
||||
|
||||
// Also expand all unread emails
|
||||
emails.forEach(email => {
|
||||
if (!email.keywords?.$seen) {
|
||||
idsToExpand.add(email.id);
|
||||
}
|
||||
});
|
||||
|
||||
setExpandedIds(idsToExpand);
|
||||
}
|
||||
}, [emails]);
|
||||
|
||||
const toggleExpanded = (emailId: string) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(emailId)) {
|
||||
next.delete(emailId);
|
||||
} else {
|
||||
next.add(emailId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllowExternal = (emailId: string) => {
|
||||
setAllowExternalContent(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(emailId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{t("threads.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-semibold text-foreground truncate">
|
||||
{thread.latestEmail.subject || t("email_viewer.no_subject")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("threads.messages_other", { count: emails.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Cards */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 space-y-3">
|
||||
{emails.map((email, index) => (
|
||||
<EmailCard
|
||||
key={email.id}
|
||||
email={email}
|
||||
isExpanded={expandedIds.has(email.id)}
|
||||
isLatest={index === 0}
|
||||
allowExternal={externalContentPolicy === 'allow' || allowExternalContent.has(email.id)}
|
||||
onToggleExpanded={() => toggleExpanded(email.id)}
|
||||
onAllowExternal={() => toggleAllowExternal(email.id)}
|
||||
onReply={onReply ? () => onReply(email) : undefined}
|
||||
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
|
||||
onForward={onForward ? () => onForward(email) : undefined}
|
||||
onDownloadAttachment={onDownloadAttachment}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual email card component
|
||||
interface EmailCardProps {
|
||||
email: Email;
|
||||
isExpanded: boolean;
|
||||
isLatest: boolean;
|
||||
allowExternal: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onAllowExternal: () => void;
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
|
||||
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
||||
}
|
||||
|
||||
function EmailCard({
|
||||
email,
|
||||
isExpanded,
|
||||
isLatest: _isLatest,
|
||||
allowExternal,
|
||||
onToggleExpanded,
|
||||
onAllowExternal,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onDownloadAttachment,
|
||||
onMarkAsRead,
|
||||
}: EmailCardProps) {
|
||||
const t = useTranslations();
|
||||
const sender = email.from?.[0];
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||
|
||||
// Mark as read when email is expanded
|
||||
useEffect(() => {
|
||||
// Only trigger if expanded, email is unread, and we have a handler
|
||||
if (!isExpanded || !onMarkAsRead || email.keywords?.$seen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||
|
||||
// Never auto-mark
|
||||
if (markAsReadDelay === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Instant mark
|
||||
if (markAsReadDelay === 0) {
|
||||
onMarkAsRead(email.id, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delayed mark
|
||||
const timeout = setTimeout(() => {
|
||||
onMarkAsRead(email.id, true);
|
||||
}, markAsReadDelay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]);
|
||||
|
||||
// Sanitize and prepare email HTML content
|
||||
const emailContent = useMemo(() => {
|
||||
if (!email) return { html: "", isHtml: false };
|
||||
|
||||
if (email.bodyValues) {
|
||||
let useHtmlVersion = false;
|
||||
let htmlContent = '';
|
||||
|
||||
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
|
||||
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
|
||||
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
|
||||
|
||||
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
|
||||
}
|
||||
|
||||
if (useHtmlVersion && htmlContent) {
|
||||
let blockedExternalContent = false;
|
||||
|
||||
const sanitizeConfig = {
|
||||
ADD_TAGS: ['style'],
|
||||
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
|
||||
};
|
||||
|
||||
if (!allowExternal) {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||
node.setAttribute('data-blocked-src', src);
|
||||
node.removeAttribute('src');
|
||||
node.setAttribute('alt', '[Image blocked]');
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
if (node.hasAttribute('style')) {
|
||||
const style = node.getAttribute('style');
|
||||
if (style && /url\s*\(/i.test(style)) {
|
||||
const cleanStyle = style.replace(/url\s*\([^)]*\)/gi, 'none');
|
||||
node.setAttribute('style', cleanStyle);
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
DOMPurify.removeHook('afterSanitizeAttributes');
|
||||
|
||||
if (blockedExternalContent) {
|
||||
setHasBlockedContent(true);
|
||||
}
|
||||
|
||||
return { html: sanitized, isHtml: true };
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||
const htmlEscaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
|
||||
return { html: htmlEscaped, isHtml: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to preview
|
||||
if (email.preview) {
|
||||
return { html: email.preview.replace(/\n/g, '<br>'), isHtml: false };
|
||||
}
|
||||
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
||||
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
|
||||
isUnread && !isExpanded && "border-l-2 border-l-primary"
|
||||
)}>
|
||||
{/* Card Header - Always visible */}
|
||||
<button
|
||||
onClick={onToggleExpanded}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 p-4 text-left transition-colors",
|
||||
!isExpanded && "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={cn(
|
||||
"font-medium truncate",
|
||||
isUnread ? "text-foreground" : "text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
{isStarred && (
|
||||
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(email.receivedAt)}
|
||||
</div>
|
||||
{!isExpanded && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 p-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
|
||||
{/* External content warning */}
|
||||
{hasBlockedContent && !allowExternal && (
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("email_viewer.external_content_warning")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllowExternal();
|
||||
}}
|
||||
>
|
||||
{t("email_viewer.load_external_content")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="px-4 py-4">
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none dark:prose-invert",
|
||||
"prose-p:my-2 prose-headings:my-3",
|
||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{email.attachments.map((attachment, idx) => {
|
||||
const Icon = getFileIcon(attachment.name, attachment.type);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="truncate max-w-[150px]">{attachment.name || 'Attachment'}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 flex gap-2">
|
||||
{onReply && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReply();
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Reply className="w-4 h-4 mr-2" />
|
||||
{t("email_viewer.reply")}
|
||||
</Button>
|
||||
)}
|
||||
{onReplyAll && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReplyAll();
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<ReplyAll className="w-4 h-4 mr-2" />
|
||||
{t("email_viewer.reply_all")}
|
||||
</Button>
|
||||
)}
|
||||
{onForward && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onForward();
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Forward className="w-4 h-4 mr-2" />
|
||||
{t("email_viewer.forward")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle } from "lucide-react";
|
||||
|
||||
interface ThreadEmailItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
isLast?: boolean;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
}
|
||||
|
||||
export function ThreadEmailItem({
|
||||
email,
|
||||
selected,
|
||||
isLast = false,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: ThreadEmailItemProps) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative cursor-pointer transition-all duration-150",
|
||||
"pl-12 pr-4 py-2.5", // Indented for thread hierarchy
|
||||
"border-l-2 border-l-transparent",
|
||||
selected
|
||||
? "bg-accent border-l-primary"
|
||||
: "hover:bg-muted/50",
|
||||
isUnread && !selected && "bg-accent/20",
|
||||
!isLast && "border-b border-border/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-1.5 h-1.5 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Small Avatar */}
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Single line: Sender, indicators, preview, date */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"truncate text-sm flex-shrink-0 max-w-[150px]",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email?.split('@')[0] || "Unknown"}
|
||||
</span>
|
||||
|
||||
{/* Indicators */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isStarred && (
|
||||
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview snippet */}
|
||||
<span className={cn(
|
||||
"text-sm truncate flex-1 min-w-0",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/70"
|
||||
)}>
|
||||
{email.preview || "No preview"}
|
||||
</span>
|
||||
|
||||
{/* Date */}
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { getThreadColorTag } from "@/lib/thread-utils";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
|
||||
interface ThreadListItemProps {
|
||||
thread: ThreadGroup;
|
||||
isExpanded: boolean;
|
||||
selectedEmailId?: string;
|
||||
isLoading?: boolean;
|
||||
expandedEmails?: Email[]; // Full thread emails when expanded
|
||||
onToggleExpand: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void; // Mobile: open full conversation view
|
||||
}
|
||||
|
||||
// Color tag mapping
|
||||
const colorTags = {
|
||||
red: "bg-red-50 dark:bg-red-950/30",
|
||||
orange: "bg-orange-50 dark:bg-orange-950/30",
|
||||
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
|
||||
green: "bg-green-50 dark:bg-green-950/30",
|
||||
blue: "bg-blue-50 dark:bg-blue-950/30",
|
||||
purple: "bg-purple-50 dark:bg-purple-950/30",
|
||||
pink: "bg-pink-50 dark:bg-pink-950/30",
|
||||
} as const;
|
||||
|
||||
export function ThreadListItem({
|
||||
thread,
|
||||
isExpanded,
|
||||
selectedEmailId,
|
||||
isLoading = false,
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
}: ThreadListItemProps) {
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
||||
|
||||
// Get color tag from thread
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
||||
|
||||
// Check if latest email is selected
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
|
||||
// Single email thread - render as regular email, no expand
|
||||
if (emailCount === 1) {
|
||||
return (
|
||||
<SingleEmailItem
|
||||
email={latestEmail}
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get emails to display when expanded
|
||||
const emailsToShow = expandedEmails || thread.emails;
|
||||
|
||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||
// Mobile: open conversation view instead of inline expansion
|
||||
if (isMobile && onOpenConversation) {
|
||||
onOpenConversation(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: If clicking directly on the expand icon area, toggle expansion
|
||||
// Otherwise, select the latest email
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-expand-toggle]')) {
|
||||
onToggleExpand();
|
||||
} else {
|
||||
// Clicking on the row selects the latest email but also expands
|
||||
if (!isExpanded) {
|
||||
onToggleExpand();
|
||||
}
|
||||
onEmailSelect(latestEmail);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, latestEmail);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
{/* Thread Header (collapsed view) */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200",
|
||||
colorTag ? colorTag : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4" style={{
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{/* Expand/Collapse Button - Hidden on mobile */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Unread indicator */}
|
||||
{hasUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Participants and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
hasUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{participantNames.join(", ")}
|
||||
</span>
|
||||
{/* Email count badge */}
|
||||
<span className={cn(
|
||||
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||
hasUnread
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
hasUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
hasUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{latestEmail.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Thread Emails - Desktop only */}
|
||||
{isExpanded && !isMobile && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Loading conversation...
|
||||
</div>
|
||||
) : (
|
||||
emailsToShow.map((email, index) => (
|
||||
<ThreadEmailItem
|
||||
key={email.id}
|
||||
email={email}
|
||||
selected={email.id === selectedEmailId}
|
||||
isLast={index === emailsToShow.length - 1}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Single email item (for threads with only 1 email)
|
||||
function SingleEmailItem({
|
||||
email,
|
||||
selected,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
showPreview,
|
||||
colorTag,
|
||||
}: {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
}) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !colorTag && "bg-accent/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4" style={{
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{/* Spacer for alignment with thread items */}
|
||||
<div className="w-6 flex-shrink-0" />
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { debug } from "@/lib/debug";
|
||||
|
||||
export interface FallbackProps {
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: (props: FallbackProps) => ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core error boundary class component (React requirement).
|
||||
* Receives translation function as prop from the functional wrapper.
|
||||
*/
|
||||
class ErrorBoundaryCore extends Component<
|
||||
ErrorBoundaryProps & { t: (key: string) => string },
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
state: ErrorBoundaryState = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
// Always log errors
|
||||
debug.error("[ErrorBoundary]", error.message, {
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
|
||||
// Call optional error handler
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
resetError = (): void => {
|
||||
this.props.onReset?.();
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
return this.props.fallback({
|
||||
error: this.state.error,
|
||||
resetError: this.resetError,
|
||||
t: this.props.t,
|
||||
});
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional wrapper that injects translations into the error boundary.
|
||||
* Use this component to wrap any part of your UI that might throw errors.
|
||||
*
|
||||
* @example
|
||||
* <ErrorBoundary fallback={SidebarErrorFallback}>
|
||||
* <Sidebar />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
export function ErrorBoundary({
|
||||
children,
|
||||
fallback,
|
||||
onError,
|
||||
onReset,
|
||||
}: ErrorBoundaryProps) {
|
||||
const t = useTranslations("errors");
|
||||
|
||||
return (
|
||||
<ErrorBoundaryCore
|
||||
fallback={fallback}
|
||||
onError={onError}
|
||||
onReset={onReset}
|
||||
t={t}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundaryCore>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, RefreshCw, Inbox, Mail, Settings, FolderOpen } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { FallbackProps } from "./error-boundary";
|
||||
|
||||
/**
|
||||
* Full-page error fallback for route-level errors.
|
||||
*/
|
||||
export function PageErrorFallback({ error: _error, resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
{t("page_error_title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t("page_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar error fallback - matches sidebar width (256px).
|
||||
*/
|
||||
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="w-64 h-full border-r border-border bg-secondary flex flex-col items-center justify-center p-4">
|
||||
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||
{t("sidebar_error")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={resetError}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
{t("reload")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email list error fallback - matches email list panel width (384px).
|
||||
*/
|
||||
export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="w-full h-full bg-background flex flex-col items-center justify-center p-4">
|
||||
<Inbox className="w-12 h-12 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||
{t("email_list_error")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("reload_emails")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email viewer error fallback - fills remaining space (flex-1).
|
||||
*/
|
||||
export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-muted/30 p-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<Mail className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("viewer_error_title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center mb-6 max-w-md">
|
||||
{t("viewer_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email composer modal error fallback.
|
||||
*/
|
||||
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
|
||||
<AlertCircle className="w-10 h-10 text-amber-500 mb-3" />
|
||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||
{t("composer_error")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={resetError}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings page error fallback.
|
||||
*/
|
||||
export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8">
|
||||
<Settings className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("settings_error_title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center mb-6">
|
||||
{t("settings_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("reload_settings")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { ErrorBoundary } from "./error-boundary";
|
||||
export type { FallbackProps } from "./error-boundary";
|
||||
export {
|
||||
PageErrorFallback,
|
||||
SidebarErrorFallback,
|
||||
EmailListErrorFallback,
|
||||
EmailViewerErrorFallback,
|
||||
ComposerErrorFallback,
|
||||
SettingsErrorFallback,
|
||||
} from "./error-fallbacks";
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Keyboard } from "lucide-react";
|
||||
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
|
||||
const t = useTranslations();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on any key press
|
||||
useEffect(() => {
|
||||
const handleKeyDown = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={cn(
|
||||
"bg-background border border-border rounded-lg shadow-xl",
|
||||
"w-full max-w-2xl max-h-[80vh] overflow-hidden",
|
||||
"animate-in zoom-in-95 duration-200"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Keyboard className="w-5 h-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{t("shortcuts.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(80vh-80px)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Navigation Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.navigation")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{KEYBOARD_SHORTCUTS.navigation.map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Actions Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.actions")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{KEYBOARD_SHORTCUTS.actions.map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Global Section */}
|
||||
<section className="md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.global")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{KEYBOARD_SHORTCUTS.global.map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Threads Section */}
|
||||
<section className="md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
|
||||
{t("shortcuts.sections.threads")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{KEYBOARD_SHORTCUTS.threads.map((shortcut) => (
|
||||
<ShortcutRow
|
||||
key={shortcut.key}
|
||||
shortcutKey={shortcut.key}
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer tip */}
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t("shortcuts.tip")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutRow({
|
||||
shortcutKey,
|
||||
description,
|
||||
}: {
|
||||
shortcutKey: string;
|
||||
description: string;
|
||||
}) {
|
||||
// Split keys by " / " to render multiple key badges
|
||||
const keys = shortcutKey.split(" / ");
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-sm text-muted-foreground">{description}</span>
|
||||
<div className="flex items-center gap-1.5 ml-4">
|
||||
{keys.map((key, index) => (
|
||||
<span key={index}>
|
||||
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
|
||||
<kbd
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"px-2 py-0.5 text-xs font-mono font-medium",
|
||||
"bg-muted border border-border rounded",
|
||||
"text-foreground shadow-sm",
|
||||
"min-w-[24px]"
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MobileHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
onCompose?: () => void;
|
||||
onSearch?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MobileHeader({
|
||||
title,
|
||||
showBack = false,
|
||||
onBack,
|
||||
onCompose,
|
||||
onSearch,
|
||||
className,
|
||||
}: MobileHeaderProps) {
|
||||
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
|
||||
|
||||
const handleLeftAction = () => {
|
||||
if (showBack && onBack) {
|
||||
onBack();
|
||||
} else if (showBack) {
|
||||
goBack();
|
||||
} else {
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
|
||||
"md:hidden", // Only visible on mobile
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left action: Menu or Back button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLeftAction}
|
||||
className="h-10 w-10"
|
||||
aria-label={showBack ? "Go back" : "Toggle menu"}
|
||||
>
|
||||
{showBack ? (
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
) : sidebarOpen ? (
|
||||
<X className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="font-semibold text-lg truncate">{title}</h1>
|
||||
</div>
|
||||
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{onSearch && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onSearch}
|
||||
className="h-10 w-10"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{onCompose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onCompose}
|
||||
className="h-10 w-10 text-primary"
|
||||
aria-label="Compose"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewer header for mobile - shows when viewing an email
|
||||
*/
|
||||
interface MobileViewerHeaderProps {
|
||||
subject?: string;
|
||||
onBack: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MobileViewerHeader({
|
||||
subject,
|
||||
onBack,
|
||||
onDelete: _onDelete,
|
||||
onArchive: _onArchive,
|
||||
className,
|
||||
}: MobileViewerHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0",
|
||||
"md:hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-10 w-10"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<h1 className="flex-1 font-medium text-sm truncate px-2 text-center">
|
||||
{subject || "(No Subject)"}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center">
|
||||
{/* Placeholder for additional actions - kept minimal */}
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Inbox,
|
||||
Send,
|
||||
File,
|
||||
Star,
|
||||
Trash2,
|
||||
Archive,
|
||||
PenSquare,
|
||||
Search,
|
||||
Menu,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
ChevronUp,
|
||||
Users,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
||||
|
||||
interface SidebarProps {
|
||||
mailboxes: Mailbox[];
|
||||
selectedMailbox?: string;
|
||||
onMailboxSelect?: (mailboxId: string) => void;
|
||||
onCompose?: () => void;
|
||||
onLogout?: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
quota?: { used: number; total: number } | null;
|
||||
isPushConnected?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Map role to icon
|
||||
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
|
||||
const lowerName = name?.toLowerCase() || "";
|
||||
|
||||
// Shared folders root node
|
||||
if (id === 'shared-folders-root') {
|
||||
return isExpanded ? FolderOpen : Users;
|
||||
}
|
||||
|
||||
// Shared account nodes
|
||||
if (id?.startsWith('shared-account-')) {
|
||||
return isExpanded ? FolderOpen : User;
|
||||
}
|
||||
|
||||
// Shared mailboxes (but not virtual nodes)
|
||||
if (isShared && hasChildren && !id?.startsWith('shared-')) {
|
||||
return isExpanded ? FolderOpen : Folder;
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
// For folders with children, return open/closed folder icon
|
||||
return isExpanded ? FolderOpen : Folder;
|
||||
}
|
||||
|
||||
if (role === "inbox" || lowerName.includes("inbox")) return Inbox;
|
||||
if (role === "sent" || lowerName.includes("sent")) return Send;
|
||||
if (role === "drafts" || lowerName.includes("draft")) return File;
|
||||
if (role === "trash" || lowerName.includes("trash")) return Trash2;
|
||||
if (role === "archive" || lowerName.includes("archive")) return Archive;
|
||||
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
|
||||
return Inbox; // Default icon
|
||||
};
|
||||
|
||||
// Component for rendering a single mailbox node with its children
|
||||
function MailboxTreeItem({
|
||||
node,
|
||||
selectedMailbox,
|
||||
expandedFolders,
|
||||
onMailboxSelect,
|
||||
onToggleExpand,
|
||||
isCollapsed,
|
||||
}: {
|
||||
node: MailboxNode;
|
||||
selectedMailbox: string;
|
||||
expandedFolders: Set<string>;
|
||||
onMailboxSelect?: (id: string) => void;
|
||||
onToggleExpand: (id: string) => void;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(node.id);
|
||||
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
||||
const indentPixels = node.depth * 16; // 16px per depth level
|
||||
const isVirtualNode = node.id.startsWith('shared-'); // Virtual nodes for shared folder organization
|
||||
|
||||
// Drag and drop functionality
|
||||
const { isDragging: globalDragging } = useDragDropContext();
|
||||
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
|
||||
mailbox: node,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
{...(globalDragging ? dropHandlers : {})}
|
||||
className={cn(
|
||||
"group w-full flex items-center px-2 py-1 text-sm transition-all duration-200",
|
||||
selectedMailbox === node.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-foreground",
|
||||
node.depth === 0 && "font-medium", // Root folders are slightly bolder
|
||||
// Drop target visual feedback
|
||||
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
|
||||
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
|
||||
)}
|
||||
>
|
||||
{/* Expand/Collapse Chevron */}
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand(node.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||
"hover:bg-muted active:bg-accent"
|
||||
)}
|
||||
style={{ marginLeft: indentPixels }}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mailbox Button */}
|
||||
<button
|
||||
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
|
||||
disabled={isVirtualNode}
|
||||
className={cn(
|
||||
"flex-1 flex items-center text-left py-1 px-1 rounded",
|
||||
"transition-colors duration-150",
|
||||
isVirtualNode && "cursor-default"
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
|
||||
}}
|
||||
title={isCollapsed ? node.name : undefined}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-4 h-4 mr-2 flex-shrink-0 transition-colors",
|
||||
hasChildren && isExpanded && "text-primary",
|
||||
selectedMailbox === node.id && "text-accent-foreground",
|
||||
!hasChildren && node.depth > 0 && "text-muted-foreground",
|
||||
node.isShared && "text-blue-500" // Shared folders in blue
|
||||
)} />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
{node.unreadEmails > 0 && (
|
||||
<span className={cn(
|
||||
"text-xs rounded-full px-2 py-0.5 ml-2 font-medium",
|
||||
selectedMailbox === node.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-foreground text-background"
|
||||
)}>
|
||||
{node.unreadEmails}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Render children if expanded */}
|
||||
{hasChildren && isExpanded && !isCollapsed && (
|
||||
<div className="relative">
|
||||
{node.children.map((child) => (
|
||||
<MailboxTreeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedMailbox={selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
mailboxes = [],
|
||||
selectedMailbox = "",
|
||||
onMailboxSelect,
|
||||
onCompose,
|
||||
onLogout,
|
||||
onSearch,
|
||||
quota,
|
||||
isPushConnected = false,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const t = useTranslations('sidebar');
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Load expanded folders from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('expandedMailboxes');
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
setExpandedFolders(new Set(parsed));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse expanded mailboxes:', e);
|
||||
}
|
||||
} else {
|
||||
// By default, expand root folders that have children
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const defaultExpanded = tree
|
||||
.filter(node => node.children.length > 0)
|
||||
.map(node => node.id);
|
||||
setExpandedFolders(new Set(defaultExpanded));
|
||||
}
|
||||
}, [mailboxes]);
|
||||
|
||||
// Save expanded folders to localStorage when changed
|
||||
const handleToggleExpand = (mailboxId: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mailboxId)) {
|
||||
next.delete(mailboxId);
|
||||
} else {
|
||||
next.add(mailboxId);
|
||||
}
|
||||
localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim() && onSearch) {
|
||||
onSearch(searchQuery);
|
||||
}
|
||||
};
|
||||
|
||||
// Build hierarchical mailbox tree
|
||||
const mailboxTree = buildMailboxTree(mailboxes);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!selectedMailbox || isCollapsed) return;
|
||||
|
||||
// Find the selected node in the tree
|
||||
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === selectedMailbox) return node;
|
||||
const found = findNode(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectedNode = findNode(mailboxTree);
|
||||
if (!selectedNode) return;
|
||||
|
||||
// Handle arrow keys for expand/collapse
|
||||
if (e.key === 'ArrowRight' && selectedNode.children.length > 0) {
|
||||
// Expand folder
|
||||
if (!expandedFolders.has(selectedMailbox)) {
|
||||
handleToggleExpand(selectedMailbox);
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) {
|
||||
// Collapse folder
|
||||
if (expandedFolders.has(selectedMailbox)) {
|
||||
handleToggleExpand(selectedMailbox);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedMailbox, isCollapsed, expandedFolders, mailboxTree]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
|
||||
"bg-secondary border-border",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
{!isCollapsed && (
|
||||
<Button onClick={onCompose} className="ml-2 flex-1">
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t("compose")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 py-3">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
data-search-input
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mailbox List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="py-1">
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Render hierarchical mailbox tree */}
|
||||
{mailboxTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{/* Sliding Menu Panel */}
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 bg-background border-t border-border z-10 shadow-lg",
|
||||
"transform transition-all duration-300 ease-out",
|
||||
showMenu ? "-translate-y-12" : "translate-y-full"
|
||||
)}>
|
||||
<div className="py-2">
|
||||
{/* Storage Info */}
|
||||
{quota && quota.total > 0 && (
|
||||
<div className="px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{t("storage")}</span>
|
||||
<span className="text-foreground">
|
||||
{formatFileSize(quota.used)} / {formatFileSize(quota.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 w-full bg-muted rounded-full h-1">
|
||||
<div
|
||||
className="bg-primary h-1 rounded-full"
|
||||
style={{ width: `${Math.min((quota.used / quota.total) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border mt-2 pt-2">
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => router.push(`/${params.locale}/settings`)}
|
||||
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
{t("settings")}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Sign Out */}
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Toggle Button */}
|
||||
<div className="border-t border-border relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 flex items-center justify-between",
|
||||
"hover:bg-muted transition-colors",
|
||||
"text-sm text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Menu className="w-4 h-4" />
|
||||
Menu
|
||||
{/* Push Connection Status Indicator */}
|
||||
<span
|
||||
className="relative group"
|
||||
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
|
||||
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
{/* Tooltip on hover */}
|
||||
<span className={cn(
|
||||
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1",
|
||||
"bg-popover text-popover-foreground text-xs rounded shadow-lg",
|
||||
"whitespace-nowrap opacity-0 group-hover:opacity-100",
|
||||
"pointer-events-none transition-opacity duration-200 z-50"
|
||||
)}>
|
||||
{isPushConnected ? t("push_connected") : t("push_disconnected")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronUp className={cn(
|
||||
"w-4 h-4 transition-transform duration-200",
|
||||
showMenu ? "" : "rotate-180"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||
|
||||
useEffect(() => {
|
||||
initializeTheme();
|
||||
}, [initializeTheme]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
|
||||
export function AccountSettings() {
|
||||
const t = useTranslations('settings.account');
|
||||
const { username, serverUrl } = useAuthStore();
|
||||
const { quota } = useEmailStore();
|
||||
|
||||
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Email Address */}
|
||||
<SettingItem label={t('email.label')}>
|
||||
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
|
||||
</SettingItem>
|
||||
|
||||
{/* Server */}
|
||||
<SettingItem label={t('server.label')}>
|
||||
<span className="text-sm text-foreground truncate max-w-xs">
|
||||
{serverUrl || t('../../common.unknown')}
|
||||
</span>
|
||||
</SettingItem>
|
||||
|
||||
{/* Storage */}
|
||||
{quota && quota.total > 0 && (
|
||||
<SettingItem
|
||||
label={t('storage.label')}
|
||||
description={t('storage.used', {
|
||||
used: formatFileSize(quota.used),
|
||||
total: formatFileSize(quota.total),
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-sm text-foreground">
|
||||
{t('storage.percentage', { percent: quotaPercentage })}
|
||||
</span>
|
||||
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${quotaPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function AdvancedSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const tCommon = useTranslations('common');
|
||||
const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
useSettingsStore();
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleExport = () => {
|
||||
const settingsJson = exportSettings();
|
||||
const blob = new Blob([settingsJson], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `webmail-settings-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const json = event.target?.result as string;
|
||||
const success = importSettings(json);
|
||||
if (success) {
|
||||
alert(t('../../settings.import_success'));
|
||||
} else {
|
||||
alert(t('../../settings.import_error'));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (showResetConfirm) {
|
||||
resetToDefaults();
|
||||
setShowResetConfirm(false);
|
||||
alert(t('../../settings.save_success'));
|
||||
} else {
|
||||
setShowResetConfirm(true);
|
||||
setTimeout(() => setShowResetConfirm(false), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Debug Mode */}
|
||||
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')}>
|
||||
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
{/* Export Settings */}
|
||||
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
{t('export_settings.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Import Settings */}
|
||||
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||
{t('import_settings.button')}
|
||||
</Button>
|
||||
</>
|
||||
</SettingItem>
|
||||
|
||||
{/* Reset Settings */}
|
||||
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
|
||||
<Button
|
||||
variant={showResetConfirm ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, listDensity, animationsEnabled, updateSetting } = useSettingsStore();
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Theme */}
|
||||
<SettingItem label={t('theme.label')} description={t('theme.description')}>
|
||||
<RadioGroup
|
||||
value={theme}
|
||||
onChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
|
||||
options={[
|
||||
{ value: 'light', label: t('theme.light') },
|
||||
{ value: 'dark', label: t('theme.dark') },
|
||||
{ value: 'system', label: t('theme.system') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Font Size */}
|
||||
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
|
||||
<RadioGroup
|
||||
value={fontSize}
|
||||
onChange={(value) => updateSetting('fontSize', value as 'small' | 'medium' | 'large')}
|
||||
options={[
|
||||
{ value: 'small', label: t('font_size.small') },
|
||||
{ value: 'medium', label: t('font_size.medium') },
|
||||
{ value: 'large', label: t('font_size.large') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* List Density */}
|
||||
<SettingItem label={t('list_density.label')} description={t('list_density.description')}>
|
||||
<RadioGroup
|
||||
value={listDensity}
|
||||
onChange={(value) =>
|
||||
updateSetting('listDensity', value as 'compact' | 'regular' | 'comfortable')
|
||||
}
|
||||
options={[
|
||||
{ value: 'compact', label: t('list_density.compact') },
|
||||
{ value: 'regular', label: t('list_density.regular') },
|
||||
{ value: 'comfortable', label: t('list_density.comfortable') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Animations */}
|
||||
<SettingItem label={t('animations.label')} description={t('animations.description')}>
|
||||
<ToggleSwitch
|
||||
checked={animationsEnabled}
|
||||
onChange={(checked) => updateSetting('animationsEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
|
||||
export function EmailSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const {
|
||||
markAsReadDelay,
|
||||
deleteAction,
|
||||
showPreview,
|
||||
emailsPerPage,
|
||||
externalContentPolicy,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Mark as Read */}
|
||||
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')}>
|
||||
<Select
|
||||
value={markAsReadDelay.toString()}
|
||||
onChange={(value) => updateSetting('markAsReadDelay', parseInt(value))}
|
||||
options={[
|
||||
{ value: '0', label: t('mark_read.instant') },
|
||||
{ value: '3000', label: t('mark_read.delay_3s') },
|
||||
{ value: '5000', label: t('mark_read.delay_5s') },
|
||||
{ value: '-1', label: t('mark_read.never') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Delete Action */}
|
||||
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')}>
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
|
||||
options={[
|
||||
{ value: 'trash', label: t('delete_action.trash') },
|
||||
{ value: 'permanent', label: t('delete_action.permanent') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Show Preview */}
|
||||
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')}>
|
||||
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
{/* Emails Per Page */}
|
||||
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
|
||||
<Select
|
||||
value={emailsPerPage.toString()}
|
||||
onChange={(value) => updateSetting('emailsPerPage', parseInt(value))}
|
||||
options={[
|
||||
{ value: '25', label: t('emails_per_page.25') },
|
||||
{ value: '50', label: t('emails_per_page.50') },
|
||||
{ value: '100', label: t('emails_per_page.100') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* External Content */}
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')}>
|
||||
<Select
|
||||
value={externalContentPolicy}
|
||||
onChange={(value) =>
|
||||
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
|
||||
}
|
||||
options={[
|
||||
{ value: 'ask', label: t('external_content.ask') },
|
||||
{ value: 'block', label: t('external_content.block') },
|
||||
{ value: 'allow', label: t('external_content.allow') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsSection({ title, description, children }: SettingsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SettingItem({ label, description, children }: SettingItemProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between py-3 border-b border-border last:border-0">
|
||||
<div className="flex-1 pr-4">
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
||||
${checked ? 'bg-primary' : 'bg-muted'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-background transition-transform
|
||||
${checked ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioGroupProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export function RadioGroup({ value, onChange, options }: RadioGroupProps) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`
|
||||
px-3 py-1.5 text-xs rounded transition-colors
|
||||
${
|
||||
value === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-accent text-foreground'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export function Select({ value, onChange, options }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AvatarProps {
|
||||
name?: string;
|
||||
email?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
||||
const getInitials = () => {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
if (email) {
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
return "?";
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
const str = name || email || "";
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return `hsl(${hue}, 70%, 50%)`;
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-8 h-8 text-xs",
|
||||
md: "w-10 h-10 text-sm",
|
||||
lg: "w-12 h-12 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full flex items-center justify-center font-semibold text-white",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: getBackgroundColor() }}
|
||||
title={name || email}
|
||||
>
|
||||
{getInitials()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "ghost" | "outline" | "destructive";
|
||||
size?: "sm" | "md" | "lg" | "icon";
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "md", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-all duration-200",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||
}[variant],
|
||||
{
|
||||
sm: "h-9 px-3 text-sm",
|
||||
md: "h-10 px-4 py-2",
|
||||
lg: "h-11 px-8",
|
||||
icon: "h-10 w-10",
|
||||
}[size],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
isOpen: boolean;
|
||||
position: Position;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
({ isOpen, position, onClose: _onClose, children }, ref) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted || !isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
)}
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
}}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<div className="py-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ContextMenu.displayName = "ContextMenu";
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
destructive = false,
|
||||
shortcut,
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-sm text-left flex items-center gap-2",
|
||||
"transition-colors duration-100",
|
||||
"focus:outline-none focus:bg-muted",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
!disabled && "hover:bg-muted cursor-pointer",
|
||||
destructive && !disabled && "text-destructive hover:bg-destructive/10 focus:bg-destructive/10"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return;
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
<span className="flex-1">{label}</span>
|
||||
{shortcut && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">{shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenuSeparator() {
|
||||
return <div className="h-px bg-border my-1" role="separator" />;
|
||||
}
|
||||
|
||||
interface ContextMenuSubMenuProps {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ContextMenuSubMenu({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right");
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const subMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && itemRef.current) {
|
||||
const rect = itemRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
// Check if submenu would overflow right edge
|
||||
if (rect.right + 200 > viewportWidth - 10) {
|
||||
setSubMenuPosition("left");
|
||||
} else {
|
||||
setSubMenuPosition("right");
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsOpen(true)}
|
||||
onMouseLeave={() => setIsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-sm flex items-center gap-2",
|
||||
"transition-colors duration-100 cursor-pointer",
|
||||
"hover:bg-muted"
|
||||
)}
|
||||
role="menuitem"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
<span className="flex-1">{label}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={subMenuRef}
|
||||
className={cn(
|
||||
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
|
||||
"animate-in fade-in-0 zoom-in-95 duration-100",
|
||||
subMenuPosition === "right" ? "left-full ml-1" : "right-full mr-1"
|
||||
)}
|
||||
role="menu"
|
||||
>
|
||||
<div className="py-1 max-h-[300px] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContextMenuHeaderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ContextMenuHeader({ children }: ContextMenuHeaderProps) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"placeholder:text-muted-foreground",
|
||||
"hover:border-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { locales } from '@/i18n/request';
|
||||
|
||||
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const t = useTranslations('language');
|
||||
const currentLocale = params.locale as string;
|
||||
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
// Get the path without the locale prefix
|
||||
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
|
||||
|
||||
// Navigate to the same page with the new locale
|
||||
router.push(`/${newLocale}${pathWithoutLocale}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1 p-1 bg-muted rounded-lg", className)}>
|
||||
{locales.map((locale) => (
|
||||
<button
|
||||
key={locale}
|
||||
onClick={() => handleLanguageChange(locale)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
|
||||
"text-foreground",
|
||||
currentLocale === locale
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "hover:bg-accent/50"
|
||||
)}
|
||||
title={t(locale === 'en' ? 'english' : 'french')}
|
||||
>
|
||||
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
|
||||
<span className="hidden sm:inline">
|
||||
{locale.toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ToastType = "success" | "error" | "info" | "warning";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: Toast;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
};
|
||||
|
||||
const styles = {
|
||||
success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200",
|
||||
error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200",
|
||||
info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200",
|
||||
warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200",
|
||||
};
|
||||
|
||||
export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
const Icon = icons[toast.type];
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration && toast.duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose(toast.id);
|
||||
}, toast.duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
|
||||
styles[toast.type],
|
||||
toast.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (toast.onClick) {
|
||||
toast.onClick();
|
||||
onClose(toast.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{toast.title}</h4>
|
||||
{toast.message && (
|
||||
<p className="text-sm mt-1 opacity-90">{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(toast.id);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
|
||||
interface DragDropState {
|
||||
isDragging: boolean;
|
||||
draggedEmails: Email[];
|
||||
dragCount: number;
|
||||
sourceMailboxId: string | null;
|
||||
}
|
||||
|
||||
interface DragDropContextValue extends DragDropState {
|
||||
startDrag: (emails: Email[], sourceMailboxId: string) => void;
|
||||
endDrag: () => void;
|
||||
}
|
||||
|
||||
const initialState: DragDropState = {
|
||||
isDragging: false,
|
||||
draggedEmails: [],
|
||||
dragCount: 0,
|
||||
sourceMailboxId: null,
|
||||
};
|
||||
|
||||
const DragDropContext = createContext<DragDropContextValue | null>(null);
|
||||
|
||||
export function DragDropProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<DragDropState>(initialState);
|
||||
|
||||
const startDrag = useCallback((emails: Email[], sourceMailboxId: string) => {
|
||||
setState({
|
||||
isDragging: true,
|
||||
draggedEmails: emails,
|
||||
dragCount: emails.length,
|
||||
sourceMailboxId,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DragDropContext.Provider value={{ ...state, startDrag, endDrag }}>
|
||||
{children}
|
||||
</DragDropContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDragDropContext() {
|
||||
const context = useContext(DragDropContext);
|
||||
if (!context) {
|
||||
throw new Error("useDragDropContext must be used within DragDropProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsparser from "@typescript-eslint/parser";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
React: "readonly",
|
||||
JSX: "readonly",
|
||||
NodeJS: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint,
|
||||
"react": reactPlugin,
|
||||
"react-hooks": reactHooksPlugin,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_"
|
||||
}],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"no-unused-vars": "off",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
".next/**",
|
||||
"node_modules/**",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ContextMenuState<T> {
|
||||
isOpen: boolean;
|
||||
position: Position;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
interface UseContextMenuReturn<T> {
|
||||
contextMenu: ContextMenuState<T>;
|
||||
openContextMenu: (e: React.MouseEvent, data: T) => void;
|
||||
closeContextMenu: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 200;
|
||||
const MENU_HEIGHT = 320; // Approximate max height
|
||||
|
||||
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
|
||||
isOpen: false,
|
||||
position: { x: 0, y: 0 },
|
||||
data: null,
|
||||
});
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const calculatePosition = useCallback((clientX: number, clientY: number): Position => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let x = clientX;
|
||||
let y = clientY;
|
||||
|
||||
// Adjust for right edge
|
||||
if (x + MENU_WIDTH > viewportWidth - 10) {
|
||||
x = viewportWidth - MENU_WIDTH - 10;
|
||||
}
|
||||
|
||||
// Adjust for bottom edge
|
||||
if (y + MENU_HEIGHT > viewportHeight - 10) {
|
||||
y = viewportHeight - MENU_HEIGHT - 10;
|
||||
}
|
||||
|
||||
// Ensure minimum position
|
||||
x = Math.max(10, x);
|
||||
y = Math.max(10, y);
|
||||
|
||||
return { x, y };
|
||||
}, []);
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, data: T) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const position = calculatePosition(e.clientX, e.clientY);
|
||||
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
position,
|
||||
data,
|
||||
});
|
||||
}, [calculatePosition]);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Close on escape key, click outside, and scroll
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isOpen) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
// Add listeners with a slight delay to prevent immediate closing
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [contextMenu.isOpen, closeContextMenu]);
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
openContextMenu,
|
||||
closeContextMenu,
|
||||
menuRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, DragEvent } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
|
||||
interface UseEmailDragOptions {
|
||||
email: Email;
|
||||
sourceMailboxId: string;
|
||||
}
|
||||
|
||||
interface UseEmailDragReturn {
|
||||
dragHandlers: {
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
function createDragPreview(count: number): HTMLElement {
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "drag-preview";
|
||||
preview.style.cssText = `
|
||||
position: fixed;
|
||||
top: -9999px;
|
||||
left: 0;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 9999;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
`;
|
||||
preview.textContent = count === 1 ? "1 email" : `${count} emails`;
|
||||
document.body.appendChild(preview);
|
||||
return preview;
|
||||
}
|
||||
|
||||
export function useEmailDrag({ email, sourceMailboxId }: UseEmailDragOptions): UseEmailDragReturn {
|
||||
const { selectedEmailIds, emails } = useEmailStore();
|
||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
// Determine which emails to drag:
|
||||
// - If current email is selected, drag all selected
|
||||
// - Otherwise, drag only this email
|
||||
const isSelected = selectedEmailIds.has(email.id);
|
||||
const emailsToDrag = isSelected
|
||||
? emails.filter(em => selectedEmailIds.has(em.id))
|
||||
: [email];
|
||||
|
||||
// Set data transfer
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData(
|
||||
"application/x-email-ids",
|
||||
JSON.stringify(emailsToDrag.map(em => em.id))
|
||||
);
|
||||
e.dataTransfer.setData(
|
||||
"text/plain",
|
||||
emailsToDrag.map(em => em.subject || "(no subject)").join(", ")
|
||||
);
|
||||
|
||||
// Create custom drag image
|
||||
const dragPreview = createDragPreview(emailsToDrag.length);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
|
||||
// Clean up preview after drag starts (browser keeps a snapshot)
|
||||
requestAnimationFrame(() => {
|
||||
dragPreview.remove();
|
||||
});
|
||||
|
||||
startDrag(emailsToDrag, sourceMailboxId);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
// Check if this specific email is being dragged
|
||||
const isThisEmailDragging = isDragging && draggedEmails.some(em => em.id === email.id);
|
||||
|
||||
return {
|
||||
dragHandlers: {
|
||||
draggable: true,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
},
|
||||
isDragging: isThisEmailDragging,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
|
||||
export interface KeyboardShortcutHandlers {
|
||||
// Navigation
|
||||
onNextEmail?: () => void;
|
||||
onPreviousEmail?: () => void;
|
||||
onOpenEmail?: () => void;
|
||||
onCloseEmail?: () => void;
|
||||
|
||||
// Email actions
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onToggleStar?: () => void;
|
||||
onArchive?: () => void;
|
||||
onDelete?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
|
||||
// Global actions
|
||||
onCompose?: () => void;
|
||||
onFocusSearch?: () => void;
|
||||
onShowHelp?: () => void;
|
||||
onRefresh?: () => void;
|
||||
|
||||
// Selection
|
||||
onSelectAll?: () => void;
|
||||
onDeselectAll?: () => void;
|
||||
|
||||
// Thread actions
|
||||
onToggleThreadExpansion?: () => void;
|
||||
}
|
||||
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
enabled?: boolean;
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
handlers: KeyboardShortcutHandlers;
|
||||
}
|
||||
|
||||
// Check if user is typing in an input field
|
||||
function isInputFocused(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
const isInput = tagName === "input" || tagName === "textarea" || tagName === "select";
|
||||
const isContentEditable = activeElement.getAttribute("contenteditable") === "true";
|
||||
|
||||
return isInput || isContentEditable;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled = true,
|
||||
emails,
|
||||
selectedEmailId,
|
||||
handlers,
|
||||
}: UseKeyboardShortcutsOptions) {
|
||||
const handlersRef = useRef(handlers);
|
||||
|
||||
// Keep handlers ref updated
|
||||
useEffect(() => {
|
||||
handlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (isInputFocused()) return;
|
||||
|
||||
const h = handlersRef.current;
|
||||
const key = event.key.toLowerCase();
|
||||
const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
|
||||
|
||||
// Shortcuts that work with modifiers
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
switch (key) {
|
||||
case "a":
|
||||
// Ctrl/Cmd + A: Select all
|
||||
event.preventDefault();
|
||||
h.onSelectAll?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Shortcuts that should NOT work with modifiers
|
||||
if (hasModifier) return;
|
||||
|
||||
switch (key) {
|
||||
// Navigation
|
||||
case "j":
|
||||
case "arrowdown":
|
||||
event.preventDefault();
|
||||
h.onNextEmail?.();
|
||||
break;
|
||||
|
||||
case "k":
|
||||
case "arrowup":
|
||||
event.preventDefault();
|
||||
h.onPreviousEmail?.();
|
||||
break;
|
||||
|
||||
case "enter":
|
||||
case "o":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onOpenEmail?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "escape":
|
||||
event.preventDefault();
|
||||
h.onCloseEmail?.();
|
||||
h.onDeselectAll?.();
|
||||
break;
|
||||
|
||||
// Email actions (only when email is selected)
|
||||
case "r":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
h.onReplyAll?.();
|
||||
} else {
|
||||
h.onReply?.();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "a":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onReplyAll?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "f":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onForward?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "s":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onToggleStar?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "e":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onArchive?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "#":
|
||||
case "delete":
|
||||
case "backspace":
|
||||
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) {
|
||||
event.preventDefault();
|
||||
h.onDelete?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "u":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onMarkAsUnread?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "i":
|
||||
if (selectedEmailId && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
h.onMarkAsRead?.();
|
||||
}
|
||||
break;
|
||||
|
||||
// Global actions
|
||||
case "c":
|
||||
event.preventDefault();
|
||||
h.onCompose?.();
|
||||
break;
|
||||
|
||||
case "/":
|
||||
event.preventDefault();
|
||||
h.onFocusSearch?.();
|
||||
break;
|
||||
|
||||
case "?":
|
||||
event.preventDefault();
|
||||
h.onShowHelp?.();
|
||||
break;
|
||||
|
||||
case "g":
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
h.onRefresh?.();
|
||||
}
|
||||
break;
|
||||
|
||||
// Thread actions
|
||||
case "x":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onToggleThreadExpansion?.();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[selectedEmailId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, handleKeyDown]);
|
||||
|
||||
// Helper to get next/previous email
|
||||
const getAdjacentEmailIndex = useCallback(
|
||||
(direction: "next" | "previous"): number => {
|
||||
if (emails.length === 0) return -1;
|
||||
|
||||
if (!selectedEmailId) {
|
||||
// If no email selected, select first (for next) or last (for previous)
|
||||
return direction === "next" ? 0 : emails.length - 1;
|
||||
}
|
||||
|
||||
const currentIndex = emails.findIndex((e) => e.id === selectedEmailId);
|
||||
if (currentIndex === -1) return direction === "next" ? 0 : emails.length - 1;
|
||||
|
||||
if (direction === "next") {
|
||||
return currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
} else {
|
||||
return currentIndex > 0 ? currentIndex - 1 : currentIndex;
|
||||
}
|
||||
},
|
||||
[emails, selectedEmailId]
|
||||
);
|
||||
|
||||
return { getAdjacentEmailIndex };
|
||||
}
|
||||
|
||||
// Shortcut definitions for the help modal
|
||||
export const KEYBOARD_SHORTCUTS = {
|
||||
navigation: [
|
||||
{ key: "j / ↓", description: "shortcuts.navigation.next_email" },
|
||||
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
|
||||
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
|
||||
{ key: "Esc", description: "shortcuts.navigation.close_email" },
|
||||
],
|
||||
actions: [
|
||||
{ key: "r", description: "shortcuts.actions.reply" },
|
||||
{ key: "R / a", description: "shortcuts.actions.reply_all" },
|
||||
{ key: "f", description: "shortcuts.actions.forward" },
|
||||
{ key: "s", description: "shortcuts.actions.star" },
|
||||
{ key: "e", description: "shortcuts.actions.archive" },
|
||||
{ key: "# / Del", description: "shortcuts.actions.delete" },
|
||||
{ key: "u", description: "shortcuts.actions.mark_unread" },
|
||||
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
|
||||
],
|
||||
global: [
|
||||
{ key: "c", description: "shortcuts.global.compose" },
|
||||
{ key: "/", description: "shortcuts.global.search" },
|
||||
{ key: "?", description: "shortcuts.global.help" },
|
||||
{ key: "Shift + G", description: "shortcuts.global.refresh" },
|
||||
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
|
||||
],
|
||||
threads: [
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, DragEvent } from "react";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface UseMailboxDropOptions {
|
||||
mailbox: Mailbox;
|
||||
onDropComplete?: () => void;
|
||||
}
|
||||
|
||||
interface UseMailboxDropReturn {
|
||||
dropHandlers: {
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnter: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDrop: (e: DragEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
isDropTarget: boolean;
|
||||
isValidDropTarget: boolean;
|
||||
isInvalidDropTarget: boolean;
|
||||
}
|
||||
|
||||
export function useMailboxDrop({ mailbox, onDropComplete }: UseMailboxDropOptions): UseMailboxDropReturn {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
|
||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
||||
|
||||
// Determine if this is a valid drop target
|
||||
const isValidTarget = useCallback(() => {
|
||||
if (!isDragging) return false;
|
||||
|
||||
// Cannot drop on same mailbox
|
||||
if (mailbox.id === sourceMailboxId) return false;
|
||||
|
||||
// Check if mailbox accepts items
|
||||
if (!mailbox.myRights?.mayAddItems) return false;
|
||||
|
||||
// Virtual nodes (shared folder headers) cannot be drop targets
|
||||
if (mailbox.id.startsWith("shared-")) return false;
|
||||
|
||||
// For shared mailboxes, check account compatibility
|
||||
if (mailbox.isShared && draggedEmails[0]) {
|
||||
// Get the source mailbox's account ID from the store
|
||||
const mailboxes = useEmailStore.getState().mailboxes;
|
||||
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
|
||||
// Cross-account moves are not supported
|
||||
if (sourceMb?.accountId !== mailbox.accountId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (isValidTarget()) {
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
}
|
||||
}, [isValidTarget]);
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only leave if actually leaving the element (not entering a child)
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (!e.currentTarget.contains(relatedTarget)) {
|
||||
setIsOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(false);
|
||||
|
||||
if (!client || !isValidTarget()) {
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const emailIdsJson = e.dataTransfer.getData("application/x-email-ids");
|
||||
if (!emailIdsJson) {
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const emailIds: string[] = JSON.parse(emailIdsJson);
|
||||
|
||||
// Get the destination mailbox ID (use originalId for shared folders)
|
||||
const destinationId = mailbox.originalId || mailbox.id;
|
||||
|
||||
// Move emails one by one (store handles counter updates)
|
||||
for (const emailId of emailIds) {
|
||||
await moveToMailbox(client, emailId, destinationId);
|
||||
}
|
||||
|
||||
// Clear selection if any selected emails were moved
|
||||
if (emailIds.some(id => selectedEmailIds.has(id))) {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
// Refresh the current mailbox view
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
|
||||
// Show success toast
|
||||
if (emailIds.length === 1) {
|
||||
toast.success("Email moved", `Moved to ${mailbox.name}`);
|
||||
} else {
|
||||
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
|
||||
}
|
||||
|
||||
onDropComplete?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to move emails:", error);
|
||||
toast.error("Move failed", "Could not move emails to the selected folder");
|
||||
} finally {
|
||||
endDrag();
|
||||
}
|
||||
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete]);
|
||||
|
||||
const valid = isValidTarget();
|
||||
|
||||
return {
|
||||
dropHandlers: {
|
||||
onDragOver: handleDragOver,
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
isDropTarget: isOver && isDragging,
|
||||
isValidDropTarget: isOver && valid,
|
||||
isInvalidDropTarget: isOver && isDragging && !valid,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
|
||||
// Tailwind v4 breakpoints
|
||||
const BREAKPOINTS = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SSR-safe media query hook
|
||||
* Returns false during SSR to prevent hydration mismatch
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
setMatches(mediaQuery.matches);
|
||||
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect device type and sync with UI store
|
||||
* Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px
|
||||
*/
|
||||
export function useDeviceDetection() {
|
||||
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
|
||||
|
||||
const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
|
||||
const isTabletQuery = useMediaQuery(
|
||||
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
|
||||
);
|
||||
const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
|
||||
|
||||
useEffect(() => {
|
||||
setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
|
||||
}, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType]);
|
||||
|
||||
return { isMobile, isTablet, isDesktop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hooks for specific breakpoints
|
||||
*/
|
||||
export function useIsMobile() {
|
||||
return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
|
||||
}
|
||||
|
||||
export function useIsTablet() {
|
||||
return useMediaQuery(
|
||||
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsDesktop() {
|
||||
return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
|
||||
}
|
||||
|
||||
export function useBreakpoint(breakpoint: keyof typeof BREAKPOINTS) {
|
||||
return useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
|
||||
export const locales = ['en', 'fr'] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
export const defaultLocale: Locale = 'en';
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Get the locale from the request or use default
|
||||
let locale = await requestLocale || defaultLocale;
|
||||
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!(locales as readonly string[]).includes(locale)) {
|
||||
locale = defaultLocale;
|
||||
}
|
||||
|
||||
// Use static imports for better compatibility
|
||||
const messages = locale === 'fr'
|
||||
? (await import('../locales/fr/common.json')).default
|
||||
: (await import('../locales/en/common.json')).default;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages,
|
||||
timeZone: 'Europe/Paris',
|
||||
now: new Date()
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
/**
|
||||
* Debug logger that respects the debugMode setting.
|
||||
* Use this instead of console.log for conditional debug output.
|
||||
*/
|
||||
export const debug = {
|
||||
/**
|
||||
* Log a debug message (only when debugMode is enabled)
|
||||
*/
|
||||
log: (...args: unknown[]) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.log('[DEBUG]', ...args);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log a warning message (only when debugMode is enabled)
|
||||
*/
|
||||
warn: (...args: unknown[]) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.warn('[DEBUG]', ...args);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log an error message (always logs, regardless of debugMode)
|
||||
*/
|
||||
error: (...args: unknown[]) => {
|
||||
console.error('[ERROR]', ...args);
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a collapsed console group (only when debugMode is enabled)
|
||||
*/
|
||||
group: (label: string) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.group(`[DEBUG] ${label}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* End a console group (only when debugMode is enabled)
|
||||
*/
|
||||
groupEnd: () => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.groupEnd();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a performance timer (only when debugMode is enabled)
|
||||
*/
|
||||
time: (label: string) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.time(`[DEBUG] ${label}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* End a performance timer (only when debugMode is enabled)
|
||||
*/
|
||||
timeEnd: (label: string) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.timeEnd(`[DEBUG] ${label}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log a table (only when debugMode is enabled)
|
||||
*/
|
||||
table: (data: unknown) => {
|
||||
if (useSettingsStore.getState().debugMode) {
|
||||
console.table(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { AuthenticationResults } from './jmap/types';
|
||||
|
||||
/**
|
||||
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
|
||||
*/
|
||||
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
||||
const results: AuthenticationResults = {};
|
||||
|
||||
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
type DmarcResult = 'pass' | 'fail' | 'none';
|
||||
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
|
||||
|
||||
// Parse SPF
|
||||
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/);
|
||||
if (spfMatch) {
|
||||
results.spf = {
|
||||
result: spfMatch[1] as SpfResult,
|
||||
domain: spfMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
// Parse DKIM
|
||||
const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s]+))?(?:\s+header\.s=([^\s]+))?/);
|
||||
if (dkimMatch) {
|
||||
results.dkim = {
|
||||
result: dkimMatch[1] as DkimResult,
|
||||
domain: dkimMatch[2],
|
||||
selector: dkimMatch[3]
|
||||
};
|
||||
}
|
||||
|
||||
// Parse DMARC
|
||||
const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s]+))?(?:\s+policy\.dmarc=(\w+))?/);
|
||||
if (dmarcMatch) {
|
||||
results.dmarc = {
|
||||
result: dmarcMatch[1] as DmarcResult,
|
||||
domain: dmarcMatch[2],
|
||||
policy: dmarcMatch[3] as DmarcPolicy | undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Parse IP reverse lookup
|
||||
const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
|
||||
if (iprevMatch) {
|
||||
results.iprev = {
|
||||
result: iprevMatch[1] as 'pass' | 'fail',
|
||||
ip: iprevMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse spam score from X-Spam-Result or X-Spam-Status headers
|
||||
*/
|
||||
export function parseSpamScore(header: string): { score: number; status: string } | null {
|
||||
// Try X-Spam-Status format: "No, score=-0.25"
|
||||
const statusMatch = header.match(/^(Yes|No),?\s+score=([-\d.]+)/i);
|
||||
if (statusMatch) {
|
||||
return {
|
||||
status: statusMatch[1].toLowerCase(),
|
||||
score: parseFloat(statusMatch[2])
|
||||
};
|
||||
}
|
||||
|
||||
// Try to extract just the score
|
||||
const scoreMatch = header.match(/score[=:]?\s*([-\d.]+)/i);
|
||||
if (scoreMatch) {
|
||||
const score = parseFloat(scoreMatch[1]);
|
||||
return {
|
||||
score,
|
||||
status: score > 5 ? 'spam' : 'ham'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Received headers to extract mail routing path
|
||||
*/
|
||||
interface ReceivedHeaderInfo {
|
||||
from: string;
|
||||
by: string;
|
||||
timestamp?: string;
|
||||
protocol?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function parseReceivedHeaders(headers: string[]): ReceivedHeaderInfo[] {
|
||||
const path: ReceivedHeaderInfo[] = [];
|
||||
|
||||
for (const header of headers) {
|
||||
const fromMatch = header.match(/from\s+([^\s]+)(?:\s+\([^)]+\))?/);
|
||||
const byMatch = header.match(/by\s+([^\s]+)/);
|
||||
const dateMatch = header.match(/;\s+(.+)$/);
|
||||
const protoMatch = header.match(/with\s+(\w+)/);
|
||||
const idMatch = header.match(/id\s+([^\s;]+)/);
|
||||
|
||||
if (fromMatch || byMatch) {
|
||||
path.push({
|
||||
from: fromMatch?.[1] || 'unknown',
|
||||
by: byMatch?.[1] || 'unknown',
|
||||
timestamp: dateMatch?.[1],
|
||||
protocol: protoMatch?.[1],
|
||||
id: idMatch?.[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable size
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security status color and icon based on result
|
||||
*/
|
||||
export function getSecurityStatus(result?: string): {
|
||||
color: string;
|
||||
icon: 'check' | 'x' | 'alert' | 'minus';
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
} {
|
||||
switch (result) {
|
||||
case 'pass':
|
||||
return {
|
||||
color: 'text-green-700 dark:text-green-400',
|
||||
icon: 'check',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
||||
borderColor: 'border-l-4 border-green-600 dark:border-green-500'
|
||||
};
|
||||
case 'fail':
|
||||
case 'permerror':
|
||||
return {
|
||||
color: 'text-red-700 dark:text-red-400',
|
||||
icon: 'x',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
||||
borderColor: 'border-l-4 border-red-600 dark:border-red-500'
|
||||
};
|
||||
case 'softfail':
|
||||
case 'neutral':
|
||||
case 'temperror':
|
||||
return {
|
||||
color: 'text-amber-700 dark:text-amber-400',
|
||||
icon: 'alert',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
||||
borderColor: 'border-l-4 border-amber-600 dark:border-amber-500'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'text-gray-700 dark:text-gray-400',
|
||||
icon: 'minus',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
||||
borderColor: 'border-l-4 border-gray-400 dark:border-gray-600'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse X-Spam-LLM header to extract AI verdict and explanation
|
||||
*/
|
||||
export function parseSpamLLM(header: string): { verdict: string; explanation: string } | null {
|
||||
// Format: "LEGITIMATE (explanation)" or "SPAM (explanation)"
|
||||
// Trim the header first to remove any leading/trailing whitespace
|
||||
const trimmed = header.trim();
|
||||
const match = trimmed.match(/^(LEGITIMATE|SPAM|SUSPICIOUS)\s*\((.+)\)\s*$/i);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
verdict: match[1].toUpperCase(),
|
||||
explanation: match[2].trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract list headers (List-Unsubscribe, List-Id, etc.)
|
||||
*/
|
||||
interface ListHeaders {
|
||||
listId?: string;
|
||||
listUnsubscribe?: string;
|
||||
listHelp?: string;
|
||||
listPost?: string;
|
||||
}
|
||||
|
||||
export function extractListHeaders(headers: Record<string, string | string[]>): ListHeaders {
|
||||
const result: ListHeaders = {};
|
||||
|
||||
if (headers['List-Id']) {
|
||||
result.listId = Array.isArray(headers['List-Id'])
|
||||
? headers['List-Id'][0]
|
||||
: headers['List-Id'];
|
||||
}
|
||||
|
||||
if (headers['List-Unsubscribe']) {
|
||||
const unsub = Array.isArray(headers['List-Unsubscribe'])
|
||||
? headers['List-Unsubscribe'][0]
|
||||
: headers['List-Unsubscribe'];
|
||||
// Extract URL from <url> format
|
||||
const match = unsub.match(/<([^>]+)>/);
|
||||
result.listUnsubscribe = match ? match[1] : unsub;
|
||||
}
|
||||
|
||||
if (headers['List-Help']) {
|
||||
result.listHelp = Array.isArray(headers['List-Help'])
|
||||
? headers['List-Help'][0]
|
||||
: headers['List-Help'];
|
||||
}
|
||||
|
||||
if (headers['List-Post']) {
|
||||
result.listPost = Array.isArray(headers['List-Post'])
|
||||
? headers['List-Post'][0]
|
||||
: headers['List-Post'];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { debug } from "./debug";
|
||||
|
||||
interface ErrorReport {
|
||||
error: Error;
|
||||
errorInfo?: React.ErrorInfo;
|
||||
zone: string;
|
||||
timestamp: Date;
|
||||
userAgent: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error to the logging system.
|
||||
* In debug mode, logs detailed information to the console.
|
||||
* Future: Can be extended to send to external error tracking services.
|
||||
*/
|
||||
export function reportError(
|
||||
error: Error,
|
||||
zone: string,
|
||||
errorInfo?: React.ErrorInfo
|
||||
): void {
|
||||
const report: ErrorReport = {
|
||||
error,
|
||||
errorInfo,
|
||||
zone,
|
||||
timestamp: new Date(),
|
||||
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "SSR",
|
||||
url: typeof window !== "undefined" ? window.location.href : "",
|
||||
};
|
||||
|
||||
// Always log errors
|
||||
debug.error(`[ErrorBoundary:${zone}]`, error.message, {
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo?.componentStack,
|
||||
url: report.url,
|
||||
timestamp: report.timestamp.toISOString(),
|
||||
});
|
||||
|
||||
// Future: Send to error tracking service (Sentry, etc.)
|
||||
// if (process.env.NODE_ENV === 'production') {
|
||||
// sendToErrorService(report);
|
||||
// }
|
||||
}
|
||||
+1437
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
export interface EmailHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Email {
|
||||
id: string;
|
||||
threadId: string;
|
||||
mailboxIds: Record<string, boolean>;
|
||||
keywords: Record<string, boolean>;
|
||||
size: number;
|
||||
receivedAt: string;
|
||||
from?: EmailAddress[];
|
||||
to?: EmailAddress[];
|
||||
cc?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
replyTo?: EmailAddress[];
|
||||
subject?: string;
|
||||
sentAt?: string;
|
||||
preview?: string;
|
||||
textBody?: EmailBodyPart[];
|
||||
htmlBody?: EmailBodyPart[];
|
||||
bodyValues?: Record<string, EmailBodyValue>;
|
||||
attachments?: Attachment[];
|
||||
hasAttachment: boolean;
|
||||
// Extended header information
|
||||
messageId?: string;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
headers?: Record<string, string | string[]>;
|
||||
// Security headers parsed
|
||||
authenticationResults?: AuthenticationResults;
|
||||
spamScore?: number;
|
||||
spamStatus?: string;
|
||||
spamLLM?: {
|
||||
verdict: string;
|
||||
explanation: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthenticationResults {
|
||||
spf?: {
|
||||
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
};
|
||||
dkim?: {
|
||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
};
|
||||
dmarc?: {
|
||||
result: 'pass' | 'fail' | 'none';
|
||||
policy?: 'reject' | 'quarantine' | 'none';
|
||||
domain?: string;
|
||||
};
|
||||
iprev?: {
|
||||
result: 'pass' | 'fail';
|
||||
ip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmailBodyValue {
|
||||
value: string;
|
||||
isEncodingProblem?: boolean;
|
||||
isTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface EmailAddress {
|
||||
name?: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface EmailBodyPart {
|
||||
partId: string;
|
||||
blobId: string;
|
||||
size: number;
|
||||
name?: string;
|
||||
type: string;
|
||||
charset?: string;
|
||||
disposition?: string;
|
||||
cid?: string;
|
||||
language?: string[];
|
||||
location?: string;
|
||||
subParts?: EmailBodyPart[];
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
partId: string;
|
||||
blobId: string;
|
||||
size: number;
|
||||
name?: string;
|
||||
type: string;
|
||||
charset?: string;
|
||||
cid?: string;
|
||||
disposition?: string;
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
id: string;
|
||||
originalId?: string; // Original JMAP ID (for shared mailboxes)
|
||||
name: string;
|
||||
parentId?: string;
|
||||
role?: string;
|
||||
sortOrder: number;
|
||||
totalEmails: number;
|
||||
unreadEmails: number;
|
||||
totalThreads: number;
|
||||
unreadThreads: number;
|
||||
myRights: {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
};
|
||||
isSubscribed: boolean;
|
||||
// Shared folder support
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
emailIds: string[];
|
||||
}
|
||||
|
||||
// Thread grouping for UI display
|
||||
export interface ThreadGroup {
|
||||
threadId: string;
|
||||
emails: Email[]; // Emails in this thread (sorted by receivedAt desc)
|
||||
latestEmail: Email; // Most recent email
|
||||
participantNames: string[];// Unique participant names
|
||||
hasUnread: boolean; // Any unread emails in thread
|
||||
hasStarred: boolean; // Any starred emails in thread
|
||||
hasAttachment: boolean; // Any email has attachment
|
||||
emailCount: number; // Total emails in thread
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
textSignature?: string;
|
||||
htmlSignature?: string;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface EmailSubmission {
|
||||
id: string;
|
||||
identityId: string;
|
||||
emailId: string;
|
||||
threadId?: string;
|
||||
envelope: {
|
||||
mailFrom: EmailAddress;
|
||||
rcptTo: EmailAddress[];
|
||||
};
|
||||
sendAt?: string;
|
||||
undoStatus: "pending" | "final" | "canceled";
|
||||
deliveryStatus?: Record<string, DeliveryStatus>;
|
||||
dsnBlobIds?: string[];
|
||||
mdnBlobIds?: string[];
|
||||
}
|
||||
|
||||
export interface DeliveryStatus {
|
||||
smtpReply: string;
|
||||
delivered: "queued" | "yes" | "no" | "unknown";
|
||||
displayed: "unknown" | "yes";
|
||||
}
|
||||
|
||||
// JMAP Push Notification Types (RFC 8620 Section 7)
|
||||
|
||||
export interface StateChange {
|
||||
'@type': 'StateChange';
|
||||
changed: {
|
||||
[accountId: string]: {
|
||||
Email?: string;
|
||||
Mailbox?: string;
|
||||
Thread?: string;
|
||||
EmailDelivery?: string;
|
||||
EmailSubmission?: string;
|
||||
Identity?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushSubscription {
|
||||
id: string;
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
} | null;
|
||||
expires: string | null;
|
||||
types: string[] | null;
|
||||
}
|
||||
|
||||
// For tracking last known states
|
||||
export interface AccountStates {
|
||||
[accountId: string]: {
|
||||
Email?: string;
|
||||
Mailbox?: string;
|
||||
Thread?: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Email, ThreadGroup } from "./jmap/types";
|
||||
|
||||
/**
|
||||
* Groups emails by their threadId and creates ThreadGroup objects for UI display.
|
||||
* Single-email threads are still returned as ThreadGroups with emailCount=1.
|
||||
*/
|
||||
export function groupEmailsByThread(emails: Email[]): ThreadGroup[] {
|
||||
if (!emails || emails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group emails by threadId
|
||||
const threadMap = new Map<string, Email[]>();
|
||||
|
||||
for (const email of emails) {
|
||||
const threadId = email.threadId;
|
||||
if (!threadMap.has(threadId)) {
|
||||
threadMap.set(threadId, []);
|
||||
}
|
||||
threadMap.get(threadId)!.push(email);
|
||||
}
|
||||
|
||||
// Convert to ThreadGroup array
|
||||
const threadGroups: ThreadGroup[] = [];
|
||||
|
||||
for (const [threadId, threadEmails] of threadMap) {
|
||||
// Sort emails by receivedAt descending (newest first)
|
||||
const sortedEmails = [...threadEmails].sort(
|
||||
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
|
||||
const latestEmail = sortedEmails[0];
|
||||
|
||||
// Collect unique participant names from all emails in thread
|
||||
const participantNames = getThreadParticipants(sortedEmails);
|
||||
|
||||
// Check for unread, starred, and attachments
|
||||
const hasUnread = sortedEmails.some(e => !e.keywords?.$seen);
|
||||
const hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
|
||||
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
|
||||
|
||||
threadGroups.push({
|
||||
threadId,
|
||||
emails: sortedEmails,
|
||||
latestEmail,
|
||||
participantNames,
|
||||
hasUnread,
|
||||
hasStarred,
|
||||
hasAttachment,
|
||||
emailCount: sortedEmails.length,
|
||||
});
|
||||
}
|
||||
|
||||
return threadGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts thread groups by their latest email's receivedAt date (newest first).
|
||||
*/
|
||||
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
|
||||
return [...groups].sort(
|
||||
(a, b) => new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts unique participant names from a list of emails.
|
||||
* Includes both senders and recipients, limited to avoid UI overflow.
|
||||
*/
|
||||
export function getThreadParticipants(emails: Email[], maxNames: number = 4): string[] {
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
|
||||
for (const email of emails) {
|
||||
// Add sender
|
||||
if (email.from && email.from.length > 0) {
|
||||
const sender = email.from[0];
|
||||
const senderName = sender.name || sender.email.split('@')[0];
|
||||
const key = sender.email.toLowerCase();
|
||||
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
names.push(senderName);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if we have enough names
|
||||
if (names.length >= maxNames) break;
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges newly fetched thread emails into an existing thread group.
|
||||
* Used when expanding a thread to show all emails (some may not have been in the original list).
|
||||
*/
|
||||
export function mergeThreadEmails(
|
||||
existingGroup: ThreadGroup,
|
||||
fetchedEmails: Email[]
|
||||
): ThreadGroup {
|
||||
// Create a map of existing emails by ID
|
||||
const emailMap = new Map<string, Email>();
|
||||
|
||||
for (const email of existingGroup.emails) {
|
||||
emailMap.set(email.id, email);
|
||||
}
|
||||
|
||||
// Add fetched emails that aren't already in the group
|
||||
for (const email of fetchedEmails) {
|
||||
if (!emailMap.has(email.id)) {
|
||||
emailMap.set(email.id, email);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert back to array and sort
|
||||
const mergedEmails = Array.from(emailMap.values()).sort(
|
||||
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
|
||||
const latestEmail = mergedEmails[0];
|
||||
const participantNames = getThreadParticipants(mergedEmails);
|
||||
const hasUnread = mergedEmails.some(e => !e.keywords?.$seen);
|
||||
const hasStarred = mergedEmails.some(e => e.keywords?.$flagged);
|
||||
const hasAttachment = mergedEmails.some(e => e.hasAttachment);
|
||||
|
||||
return {
|
||||
threadId: existingGroup.threadId,
|
||||
emails: mergedEmails,
|
||||
latestEmail,
|
||||
participantNames,
|
||||
hasUnread,
|
||||
hasStarred,
|
||||
hasAttachment,
|
||||
emailCount: mergedEmails.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets color tag from email keywords (if any).
|
||||
*/
|
||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||
if (!keywords) return null;
|
||||
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
||||
return key.replace("$color:", "");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a thread has any color tag (returns first found).
|
||||
*/
|
||||
export function getThreadColorTag(emails: Email[]): string | null {
|
||||
for (const email of emails) {
|
||||
const color = getEmailColorTag(email.keywords);
|
||||
if (color) return color;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Mailbox } from "./jmap/types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength).trim() + "...";
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Types for mailbox tree
|
||||
export interface MailboxNode extends Mailbox {
|
||||
children: MailboxNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
// Role priority for mailbox ordering (lower number = higher priority)
|
||||
const ROLE_PRIORITY: Record<string, number> = {
|
||||
inbox: 0,
|
||||
drafts: 1,
|
||||
sent: 2,
|
||||
archive: 3,
|
||||
junk: 4,
|
||||
spam: 4, // Treat spam same as junk
|
||||
trash: 5,
|
||||
};
|
||||
|
||||
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
|
||||
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||
const roleMap = new Map<string, Mailbox>();
|
||||
const result: Mailbox[] = [];
|
||||
|
||||
// First pass: collect mailboxes with roles
|
||||
mailboxes.forEach(mb => {
|
||||
if (mb.role) {
|
||||
roleMap.set(mb.role, mb);
|
||||
}
|
||||
});
|
||||
|
||||
// Second pass: filter out duplicates
|
||||
mailboxes.forEach(mb => {
|
||||
// If this mailbox has a role, always keep it
|
||||
if (mb.role) {
|
||||
result.push(mb);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a duplicate of a role-based mailbox
|
||||
const lowerName = mb.name.toLowerCase();
|
||||
const isDuplicate = Array.from(roleMap.values()).some(roleMb => {
|
||||
const roleLowerName = roleMb.name.toLowerCase();
|
||||
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
|
||||
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
|
||||
});
|
||||
|
||||
// Only keep if not a duplicate
|
||||
if (!isDuplicate) {
|
||||
result.push(mb);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build a hierarchical tree structure from flat mailbox array
|
||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
// Deduplicate mailboxes first
|
||||
const deduplicated = deduplicateMailboxes(mailboxes);
|
||||
|
||||
// Separate own and shared mailboxes
|
||||
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
|
||||
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
|
||||
|
||||
const mailboxMap = new Map<string, MailboxNode>();
|
||||
const rootMailboxes: MailboxNode[] = [];
|
||||
|
||||
// First pass: create nodes for own mailboxes
|
||||
ownMailboxes.forEach(mailbox => {
|
||||
mailboxMap.set(mailbox.id, {
|
||||
...mailbox,
|
||||
children: [],
|
||||
depth: 0
|
||||
});
|
||||
});
|
||||
|
||||
// Second pass: build tree structure for own mailboxes
|
||||
ownMailboxes.forEach(mailbox => {
|
||||
const node = mailboxMap.get(mailbox.id)!;
|
||||
|
||||
if (mailbox.parentId && mailboxMap.has(mailbox.parentId)) {
|
||||
const parent = mailboxMap.get(mailbox.parentId)!;
|
||||
parent.children.push(node);
|
||||
node.depth = parent.depth + 1;
|
||||
} else {
|
||||
// Root level mailbox or orphaned mailbox
|
||||
rootMailboxes.push(node);
|
||||
node.depth = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// If we have shared mailboxes, create a virtual "Shared Folders" parent
|
||||
if (sharedMailboxes.length > 0) {
|
||||
// Group shared mailboxes by account
|
||||
const accountGroups = new Map<string, Mailbox[]>();
|
||||
sharedMailboxes.forEach(mb => {
|
||||
const accountId = mb.accountId || 'unknown';
|
||||
if (!accountGroups.has(accountId)) {
|
||||
accountGroups.set(accountId, []);
|
||||
}
|
||||
accountGroups.get(accountId)!.push(mb);
|
||||
});
|
||||
|
||||
// Create virtual nodes for each shared account
|
||||
const sharedAccountNodes: MailboxNode[] = [];
|
||||
|
||||
accountGroups.forEach((accountMailboxes, accountId) => {
|
||||
// Create account nodes
|
||||
const accountMailboxMap = new Map<string, MailboxNode>();
|
||||
const accountRootNodes: MailboxNode[] = [];
|
||||
|
||||
// Create nodes for this account's mailboxes
|
||||
accountMailboxes.forEach(mailbox => {
|
||||
accountMailboxMap.set(mailbox.id, {
|
||||
...mailbox,
|
||||
children: [],
|
||||
depth: 2 // Account level is depth 1, these are depth 2
|
||||
});
|
||||
});
|
||||
|
||||
// Build tree for this account's mailboxes
|
||||
accountMailboxes.forEach(mailbox => {
|
||||
const node = accountMailboxMap.get(mailbox.id)!;
|
||||
|
||||
if (mailbox.parentId && accountMailboxMap.has(mailbox.parentId)) {
|
||||
const parent = accountMailboxMap.get(mailbox.parentId)!;
|
||||
parent.children.push(node);
|
||||
node.depth = parent.depth + 1;
|
||||
} else {
|
||||
accountRootNodes.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Create virtual account folder node
|
||||
const accountName = accountMailboxes[0]?.accountName || accountId;
|
||||
const accountNode: MailboxNode = {
|
||||
id: `shared-account-${accountId}`,
|
||||
name: accountName,
|
||||
sortOrder: 1000, // After all own folders
|
||||
totalEmails: accountMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
|
||||
unreadEmails: accountMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
},
|
||||
isSubscribed: true,
|
||||
accountId: accountId,
|
||||
accountName: accountName,
|
||||
isShared: true,
|
||||
children: accountRootNodes,
|
||||
depth: 1,
|
||||
};
|
||||
|
||||
sharedAccountNodes.push(accountNode);
|
||||
});
|
||||
|
||||
// Create virtual "Shared Folders" root node
|
||||
const sharedFoldersNode: MailboxNode = {
|
||||
id: 'shared-folders-root',
|
||||
name: 'Shared Folders',
|
||||
sortOrder: 999, // After all own folders
|
||||
totalEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
|
||||
unreadEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
},
|
||||
isSubscribed: true,
|
||||
isShared: true,
|
||||
children: sharedAccountNodes,
|
||||
depth: 0,
|
||||
};
|
||||
|
||||
rootMailboxes.push(sharedFoldersNode);
|
||||
}
|
||||
|
||||
// Smart multi-level sorting
|
||||
const sortNodes = (nodes: MailboxNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
// 1. Priority: Own folders before shared folders
|
||||
if (a.isShared !== b.isShared) {
|
||||
return a.isShared ? 1 : -1;
|
||||
}
|
||||
|
||||
// 2. Priority: Role-based ordering (inbox first, trash last, etc.)
|
||||
const aPriority = a.role ? (ROLE_PRIORITY[a.role] ?? 999) : 999;
|
||||
const bPriority = b.role ? (ROLE_PRIORITY[b.role] ?? 999) : 999;
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority;
|
||||
}
|
||||
|
||||
// 3. Priority: Year folders (e.g., "2025", "2024") sorted numerically descending
|
||||
const aIsYear = /^\d{4}$/.test(a.name);
|
||||
const bIsYear = /^\d{4}$/.test(b.name);
|
||||
if (aIsYear && bIsYear) {
|
||||
return parseInt(b.name) - parseInt(a.name); // Descending: 2025, 2024, 2023...
|
||||
}
|
||||
|
||||
// 4. Fallback: Server sortOrder
|
||||
if (a.sortOrder !== b.sortOrder) {
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
|
||||
// 5. Fallback: Alphabetical by name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Recursively sort children
|
||||
nodes.forEach(node => {
|
||||
if (node.children.length > 0) {
|
||||
sortNodes(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
sortNodes(rootMailboxes);
|
||||
|
||||
return rootMailboxes;
|
||||
}
|
||||
|
||||
// Flatten a mailbox tree for rendering with proper depth info
|
||||
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
|
||||
const result: MailboxNode[] = [];
|
||||
|
||||
const traverse = (nodes: MailboxNode[], depth: number = 0) => {
|
||||
nodes.forEach(node => {
|
||||
result.push({ ...node, depth });
|
||||
if (node.children.length > 0) {
|
||||
traverse(node.children, depth + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(nodes);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Webmail",
|
||||
"username_label": "Email",
|
||||
"username_placeholder": "user@example.com",
|
||||
"password_label": "Password",
|
||||
"password_placeholder": "Enter your password",
|
||||
"sign_in": "Sign in",
|
||||
"signing_in": "Signing in...",
|
||||
"error": {
|
||||
"invalid_credentials": "Invalid email or password",
|
||||
"connection_failed": "Failed to connect to the server",
|
||||
"generic": "An error occurred. Please try again."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"compose": "Compose",
|
||||
"search_placeholder": "Search mail...",
|
||||
"storage": "Storage",
|
||||
"sign_out": "Sign out",
|
||||
"settings": "Settings",
|
||||
"loading_mailboxes": "Loading mailboxes...",
|
||||
"push_connected": "Real-time updates active",
|
||||
"push_disconnected": "Real-time updates inactive",
|
||||
"theme": {
|
||||
"light": "Light mode",
|
||||
"dark": "Dark mode",
|
||||
"system": "System theme"
|
||||
},
|
||||
"mailboxes": {
|
||||
"inbox": "Inbox",
|
||||
"sent": "Sent",
|
||||
"drafts": "Drafts",
|
||||
"trash": "Trash",
|
||||
"archive": "Archive",
|
||||
"starred": "Starred",
|
||||
"all_mail": "All Mail",
|
||||
"spam": "Spam",
|
||||
"important": "Important"
|
||||
},
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "No emails",
|
||||
"no_emails_description": "Start by composing a new email",
|
||||
"loading": "Loading emails...",
|
||||
"unread": "unread",
|
||||
"to_me": "To me",
|
||||
"to_recipients": "To {{count}} recipients",
|
||||
"and_others": "and {{count}} others",
|
||||
"draft": "Draft",
|
||||
"starred": "Starred"
|
||||
},
|
||||
"email_viewer": {
|
||||
"no_email_selected": "No email selected",
|
||||
"no_email_description": "Select an email from the list to view it here",
|
||||
"no_subject": "(No Subject)",
|
||||
"reply": "Reply",
|
||||
"reply_all": "Reply All",
|
||||
"forward": "Forward",
|
||||
"delete": "Delete",
|
||||
"archive": "Archive",
|
||||
"star": "Star",
|
||||
"unstar": "Unstar",
|
||||
"mark_unread": "Mark as unread",
|
||||
"mark_read": "Mark as read",
|
||||
"print": "Print",
|
||||
"view_source": "View source",
|
||||
"email_source": "Email Source",
|
||||
"copy_source": "Copy to clipboard",
|
||||
"source_copied": "Source copied to clipboard",
|
||||
"attachments": "Attachments",
|
||||
"download": "Download",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
"date": "Date",
|
||||
"subject": "Subject",
|
||||
"show_details": "Show details",
|
||||
"hide_details": "Hide details",
|
||||
"external_content_warning": "Images and external content have been blocked",
|
||||
"load_external_content": "Load images",
|
||||
"message_details": "Message Details",
|
||||
"authentication": {
|
||||
"title": "Authentication",
|
||||
"status": {
|
||||
"verified": "Verified",
|
||||
"warning": "Warning",
|
||||
"none": "Not authenticated"
|
||||
},
|
||||
"spf": {
|
||||
"pass": "SPF Pass",
|
||||
"fail": "SPF Fail",
|
||||
"none": "No SPF"
|
||||
},
|
||||
"dkim": {
|
||||
"pass": "DKIM Valid",
|
||||
"fail": "DKIM Invalid",
|
||||
"none": "No DKIM"
|
||||
},
|
||||
"dmarc": {
|
||||
"pass": "DMARC Pass",
|
||||
"fail": "DMARC Fail",
|
||||
"none": "No DMARC"
|
||||
},
|
||||
"spam_score": "Spam Score"
|
||||
},
|
||||
"headers": {
|
||||
"routing": "Routing",
|
||||
"received": "Received",
|
||||
"message_id": "Message ID",
|
||||
"list_info": "List Information"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Color Tag",
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"green": "Green",
|
||||
"blue": "Blue",
|
||||
"purple": "Purple",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"new_message": "New Message",
|
||||
"reply": "Reply",
|
||||
"reply_all": "Reply All",
|
||||
"forward": "Forward",
|
||||
"reply_to": "Reply",
|
||||
"reply_all_to": "Reply All",
|
||||
"forward_message": "Forward",
|
||||
"to": "To",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
"subject": "Subject",
|
||||
"body_placeholder": "Write your message...",
|
||||
"send": "Send",
|
||||
"cancel": "Cancel",
|
||||
"attach": "Attach files",
|
||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
||||
"quote": {
|
||||
"reply_header": "On {{date}}, {{sender}} wrote:",
|
||||
"forward_header": "---------- Forwarded message ----------",
|
||||
"from": "From: {{sender}}",
|
||||
"date": "Date: {{date}}",
|
||||
"subject": "Subject: {{subject}}",
|
||||
"to": "To: {{recipients}}"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"search": "Search",
|
||||
"refresh": "Refresh",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"logout": "Logout",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"notifications": {
|
||||
"email_sent": "Email sent successfully",
|
||||
"email_deleted": "Email deleted",
|
||||
"email_archived": "Email archived",
|
||||
"email_starred": "Email starred",
|
||||
"email_unstarred": "Email unstarred",
|
||||
"email_marked_read": "Email marked as read",
|
||||
"email_marked_unread": "Email marked as unread",
|
||||
"copied_to_clipboard": "Copied to clipboard",
|
||||
"source_copied": "Source copied to clipboard",
|
||||
"error_sending": "Failed to send email",
|
||||
"error_deleting": "Failed to delete email",
|
||||
"error_loading": "Failed to load emails",
|
||||
"new_email": "New email",
|
||||
"new_email_from": "From {sender}",
|
||||
"click_to_view": "Click to view"
|
||||
},
|
||||
"date": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"this_week": "This week",
|
||||
"last_week": "Last week",
|
||||
"this_month": "This month",
|
||||
"older": "Older",
|
||||
"just_now": "Just now",
|
||||
"minutes_ago": "{{count}} minute ago",
|
||||
"minutes_ago_plural": "{{count}} minutes ago",
|
||||
"hours_ago": "{{count}} hour ago",
|
||||
"hours_ago_plural": "{{count}} hours ago",
|
||||
"days_ago": "{{count}} day ago",
|
||||
"days_ago_plural": "{{count}} days ago"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"english": "English",
|
||||
"french": "Français"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"back_to_mail": "Back to Mail",
|
||||
"save_success": "Settings saved successfully",
|
||||
"import_success": "Settings imported successfully",
|
||||
"import_error": "Failed to import settings",
|
||||
"reset_confirm": "Are you sure you want to reset all settings to defaults?",
|
||||
"tabs": {
|
||||
"appearance": "Appearance",
|
||||
"language": "Language & Region",
|
||||
"email": "Email Behavior",
|
||||
"composer": "Composer",
|
||||
"privacy": "Privacy & Security",
|
||||
"account": "Account",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Customize the look and feel of your webmail",
|
||||
"theme": {
|
||||
"label": "Theme",
|
||||
"description": "Choose your preferred color scheme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"font_size": {
|
||||
"label": "Font Size",
|
||||
"description": "Adjust text size for better readability",
|
||||
"small": "Small",
|
||||
"medium": "Medium",
|
||||
"large": "Large"
|
||||
},
|
||||
"list_density": {
|
||||
"label": "List Density",
|
||||
"description": "Control spacing in email lists",
|
||||
"compact": "Compact",
|
||||
"regular": "Regular",
|
||||
"comfortable": "Comfortable"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Enable Animations",
|
||||
"description": "Show smooth transitions and effects"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Language & Region",
|
||||
"description": "Configure language and regional preferences",
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"description": "Choose your preferred language",
|
||||
"english": "English",
|
||||
"french": "Français"
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Date Format",
|
||||
"description": "How dates should be displayed",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Time Format",
|
||||
"description": "Choose between 12-hour or 24-hour clock",
|
||||
"12h": "12-hour",
|
||||
"24h": "24-hour"
|
||||
},
|
||||
"first_day": {
|
||||
"label": "First Day of Week",
|
||||
"description": "Start week on Sunday or Monday",
|
||||
"sunday": "Sunday",
|
||||
"monday": "Monday"
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"title": "Email Behavior",
|
||||
"description": "Configure how emails are handled",
|
||||
"mark_read": {
|
||||
"label": "Mark as Read",
|
||||
"description": "When to mark emails as read when opened",
|
||||
"instant": "Instantly",
|
||||
"delay_3s": "After 3 seconds",
|
||||
"delay_5s": "After 5 seconds",
|
||||
"never": "Never"
|
||||
},
|
||||
"delete_action": {
|
||||
"label": "Delete Action",
|
||||
"description": "What happens when you delete an email",
|
||||
"trash": "Move to Trash",
|
||||
"permanent": "Delete Permanently"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Show Preview Text",
|
||||
"description": "Display email preview in the list"
|
||||
},
|
||||
"emails_per_page": {
|
||||
"label": "Emails Per Page",
|
||||
"description": "Number of emails to load at once",
|
||||
"25": "25 emails",
|
||||
"50": "50 emails",
|
||||
"100": "100 emails"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "External Content",
|
||||
"description": "How to handle images and external content",
|
||||
"ask": "Always ask",
|
||||
"block": "Always block",
|
||||
"allow": "Always allow"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
"title": "Composer",
|
||||
"description": "Configure email composition settings",
|
||||
"autosave": {
|
||||
"label": "Auto-save Interval",
|
||||
"description": "How often to save drafts automatically",
|
||||
"30s": "Every 30 seconds",
|
||||
"1m": "Every minute",
|
||||
"2m": "Every 2 minutes",
|
||||
"5m": "Every 5 minutes"
|
||||
},
|
||||
"send_confirmation": {
|
||||
"label": "Send Confirmation",
|
||||
"description": "Ask for confirmation before sending emails"
|
||||
},
|
||||
"default_reply": {
|
||||
"label": "Default Reply Mode",
|
||||
"description": "Default action when clicking reply",
|
||||
"reply": "Reply",
|
||||
"reply_all": "Reply All"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy & Security",
|
||||
"description": "Manage your privacy and security settings",
|
||||
"external_images": {
|
||||
"label": "Block External Images",
|
||||
"description": "Prevent tracking through external images"
|
||||
},
|
||||
"session_timeout": {
|
||||
"label": "Session Timeout",
|
||||
"description": "Automatically log out after inactivity",
|
||||
"never": "Never",
|
||||
"30m": "30 minutes",
|
||||
"1h": "1 hour",
|
||||
"4h": "4 hours"
|
||||
},
|
||||
"clear_cache": {
|
||||
"label": "Clear Cache",
|
||||
"description": "Remove cached data and temporary files",
|
||||
"button": "Clear Cache",
|
||||
"confirm": "Are you sure you want to clear the cache?",
|
||||
"success": "Cache cleared successfully"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "View your account information",
|
||||
"email": {
|
||||
"label": "Email Address",
|
||||
"value": "{{email}}"
|
||||
},
|
||||
"server": {
|
||||
"label": "JMAP Server",
|
||||
"value": "{{server}}"
|
||||
},
|
||||
"storage": {
|
||||
"label": "Storage Usage",
|
||||
"used": "{{used}} of {{total}} used",
|
||||
"percentage": "{{percent}}% used"
|
||||
},
|
||||
"last_sync": {
|
||||
"label": "Last Sync",
|
||||
"value": "{{time}}"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Advanced options and developer settings",
|
||||
"debug_mode": {
|
||||
"label": "Debug Mode",
|
||||
"description": "Enable detailed logging for troubleshooting"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "Keyboard Shortcuts",
|
||||
"description": "View available keyboard shortcuts",
|
||||
"button": "View Shortcuts"
|
||||
},
|
||||
"reset_settings": {
|
||||
"label": "Reset Settings",
|
||||
"description": "Restore all settings to default values",
|
||||
"button": "Reset to Defaults"
|
||||
},
|
||||
"export_settings": {
|
||||
"label": "Export Settings",
|
||||
"description": "Download your settings as JSON",
|
||||
"button": "Export"
|
||||
},
|
||||
"import_settings": {
|
||||
"label": "Import Settings",
|
||||
"description": "Upload settings from JSON file",
|
||||
"button": "Import"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"page_error_title": "Something went wrong",
|
||||
"page_error_description": "We encountered an unexpected error. Please try again or return to the home page.",
|
||||
"sidebar_error": "Unable to load mailboxes",
|
||||
"email_list_error": "Unable to load emails",
|
||||
"viewer_error_title": "Unable to display email",
|
||||
"viewer_error_description": "There was a problem rendering this email. It may contain unsupported content.",
|
||||
"composer_error": "Unable to load composer",
|
||||
"settings_error_title": "Settings unavailable",
|
||||
"settings_error_description": "Unable to load settings. Your preferences may not be saved.",
|
||||
"try_again": "Try again",
|
||||
"reload": "Reload",
|
||||
"reload_emails": "Reload emails",
|
||||
"reload_settings": "Reload settings",
|
||||
"retry": "Retry",
|
||||
"go_home": "Go to inbox"
|
||||
},
|
||||
"context_menu": {
|
||||
"reply": "Reply",
|
||||
"reply_all": "Reply All",
|
||||
"forward": "Forward",
|
||||
"mark_read": "Mark as Read",
|
||||
"mark_unread": "Mark as Unread",
|
||||
"star": "Star",
|
||||
"unstar": "Unstar",
|
||||
"move_to": "Move to...",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"color_tag": "Color Tag",
|
||||
"remove_color": "Remove Color",
|
||||
"items_selected": "{{count}} emails selected"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"tip": "Press ? anytime to show this help",
|
||||
"sections": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Email Actions",
|
||||
"global": "Global",
|
||||
"threads": "Threads"
|
||||
},
|
||||
"navigation": {
|
||||
"next_email": "Next email",
|
||||
"previous_email": "Previous email",
|
||||
"open_email": "Open email",
|
||||
"close_email": "Close / Deselect"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Reply",
|
||||
"reply_all": "Reply all",
|
||||
"forward": "Forward",
|
||||
"star": "Toggle star",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"mark_unread": "Mark as unread",
|
||||
"mark_read": "Mark as read"
|
||||
},
|
||||
"global": {
|
||||
"compose": "Compose new email",
|
||||
"search": "Focus search",
|
||||
"help": "Show shortcuts",
|
||||
"refresh": "Refresh emails",
|
||||
"select_all": "Select all"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Expand/collapse thread"
|
||||
}
|
||||
},
|
||||
"threads": {
|
||||
"messages_one": "{count} message",
|
||||
"messages_other": "{count} messages",
|
||||
"expand": "Expand conversation",
|
||||
"collapse": "Collapse conversation",
|
||||
"loading": "Loading conversation...",
|
||||
"mark_read": "Mark conversation as read",
|
||||
"mark_unread": "Mark conversation as unread",
|
||||
"archive": "Archive conversation",
|
||||
"delete": "Delete conversation",
|
||||
"star": "Star conversation",
|
||||
"unstar": "Unstar conversation"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Webmail",
|
||||
"username_label": "Email",
|
||||
"username_placeholder": "utilisateur@exemple.com",
|
||||
"password_label": "Mot de passe",
|
||||
"password_placeholder": "Entrez votre mot de passe",
|
||||
"sign_in": "Se connecter",
|
||||
"signing_in": "Connexion en cours...",
|
||||
"error": {
|
||||
"invalid_credentials": "Email ou mot de passe invalide",
|
||||
"connection_failed": "Échec de la connexion au serveur",
|
||||
"generic": "Une erreur s'est produite. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"compose": "Composer",
|
||||
"search_placeholder": "Rechercher un email...",
|
||||
"storage": "Stockage",
|
||||
"sign_out": "Se déconnecter",
|
||||
"settings": "Paramètres",
|
||||
"loading_mailboxes": "Chargement des boîtes mail...",
|
||||
"push_connected": "Mises à jour en temps réel actives",
|
||||
"push_disconnected": "Mises à jour en temps réel inactives",
|
||||
"theme": {
|
||||
"light": "Mode clair",
|
||||
"dark": "Mode sombre",
|
||||
"system": "Thème système"
|
||||
},
|
||||
"mailboxes": {
|
||||
"inbox": "Boîte de réception",
|
||||
"sent": "Envoyés",
|
||||
"drafts": "Brouillons",
|
||||
"trash": "Corbeille",
|
||||
"archive": "Archives",
|
||||
"starred": "Favoris",
|
||||
"all_mail": "Tous les messages",
|
||||
"spam": "Spam",
|
||||
"important": "Important"
|
||||
},
|
||||
"expand": "Développer",
|
||||
"collapse": "Réduire"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Aucun email",
|
||||
"no_emails_description": "Commencez par composer un nouvel email",
|
||||
"loading": "Chargement des emails...",
|
||||
"unread": "non lu",
|
||||
"to_me": "À moi",
|
||||
"to_recipients": "À {{count}} destinataires",
|
||||
"and_others": "et {{count}} autres",
|
||||
"draft": "Brouillon",
|
||||
"starred": "Favori"
|
||||
},
|
||||
"email_viewer": {
|
||||
"no_email_selected": "Aucun email sélectionné",
|
||||
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
|
||||
"no_subject": "(Sans objet)",
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous",
|
||||
"forward": "Transférer",
|
||||
"delete": "Supprimer",
|
||||
"archive": "Archiver",
|
||||
"star": "Marquer comme favori",
|
||||
"unstar": "Retirer des favoris",
|
||||
"mark_unread": "Marquer comme non lu",
|
||||
"mark_read": "Marquer comme lu",
|
||||
"print": "Imprimer",
|
||||
"view_source": "Voir la source",
|
||||
"email_source": "Source de l'email",
|
||||
"copy_source": "Copier dans le presse-papiers",
|
||||
"source_copied": "Source copiée dans le presse-papiers",
|
||||
"attachments": "Pièces jointes",
|
||||
"download": "Télécharger",
|
||||
"from": "De",
|
||||
"to": "À",
|
||||
"cc": "CC",
|
||||
"bcc": "CCI",
|
||||
"date": "Date",
|
||||
"subject": "Objet",
|
||||
"show_details": "Afficher les détails",
|
||||
"hide_details": "Masquer les détails",
|
||||
"external_content_warning": "Les images et le contenu externe ont été bloqués",
|
||||
"load_external_content": "Charger les images",
|
||||
"message_details": "Détails du message",
|
||||
"authentication": {
|
||||
"title": "Authentification",
|
||||
"status": {
|
||||
"verified": "Vérifié",
|
||||
"warning": "Attention",
|
||||
"none": "Non authentifié"
|
||||
},
|
||||
"spf": {
|
||||
"pass": "SPF Validé",
|
||||
"fail": "SPF Échoué",
|
||||
"none": "Pas de SPF"
|
||||
},
|
||||
"dkim": {
|
||||
"pass": "DKIM Valide",
|
||||
"fail": "DKIM Invalide",
|
||||
"none": "Pas de DKIM"
|
||||
},
|
||||
"dmarc": {
|
||||
"pass": "DMARC Validé",
|
||||
"fail": "DMARC Échoué",
|
||||
"none": "Pas de DMARC"
|
||||
},
|
||||
"spam_score": "Score de spam"
|
||||
},
|
||||
"headers": {
|
||||
"routing": "Routage",
|
||||
"received": "Reçu",
|
||||
"message_id": "ID du message",
|
||||
"list_info": "Information de liste"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Étiquette de couleur",
|
||||
"red": "Rouge",
|
||||
"orange": "Orange",
|
||||
"yellow": "Jaune",
|
||||
"green": "Vert",
|
||||
"blue": "Bleu",
|
||||
"purple": "Violet",
|
||||
"none": "Aucune"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"new_message": "Nouveau message",
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous",
|
||||
"forward": "Transférer",
|
||||
"reply_to": "Répondre",
|
||||
"reply_all_to": "Répondre à tous",
|
||||
"forward_message": "Transférer",
|
||||
"to": "À",
|
||||
"cc": "CC",
|
||||
"bcc": "CCI",
|
||||
"subject": "Objet",
|
||||
"body_placeholder": "Écrivez votre message...",
|
||||
"send": "Envoyer",
|
||||
"cancel": "Annuler",
|
||||
"attach": "Joindre des fichiers",
|
||||
"discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?",
|
||||
"quote": {
|
||||
"reply_header": "Le {{date}}, {{sender}} a écrit :",
|
||||
"forward_header": "---------- Message transféré ----------",
|
||||
"from": "De : {{sender}}",
|
||||
"date": "Date : {{date}}",
|
||||
"subject": "Objet : {{subject}}",
|
||||
"to": "À : {{recipients}}"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"close": "Fermer",
|
||||
"search": "Rechercher",
|
||||
"refresh": "Actualiser",
|
||||
"settings": "Paramètres",
|
||||
"help": "Aide",
|
||||
"logout": "Déconnexion",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"unknown": "Inconnu"
|
||||
},
|
||||
"notifications": {
|
||||
"email_sent": "Email envoyé avec succès",
|
||||
"email_deleted": "Email supprimé",
|
||||
"email_archived": "Email archivé",
|
||||
"email_starred": "Email ajouté aux favoris",
|
||||
"email_unstarred": "Email retiré des favoris",
|
||||
"email_marked_read": "Email marqué comme lu",
|
||||
"email_marked_unread": "Email marqué comme non lu",
|
||||
"copied_to_clipboard": "Copié dans le presse-papiers",
|
||||
"source_copied": "Source copiée dans le presse-papiers",
|
||||
"error_sending": "Échec de l'envoi de l'email",
|
||||
"error_deleting": "Échec de la suppression de l'email",
|
||||
"error_loading": "Échec du chargement des emails",
|
||||
"new_email": "Nouvel email",
|
||||
"new_email_from": "De {sender}",
|
||||
"click_to_view": "Cliquer pour voir"
|
||||
},
|
||||
"date": {
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"this_week": "Cette semaine",
|
||||
"last_week": "La semaine dernière",
|
||||
"this_month": "Ce mois-ci",
|
||||
"older": "Plus ancien",
|
||||
"just_now": "À l'instant",
|
||||
"minutes_ago": "Il y a {{count}} minute",
|
||||
"minutes_ago_plural": "Il y a {{count}} minutes",
|
||||
"hours_ago": "Il y a {{count}} heure",
|
||||
"hours_ago_plural": "Il y a {{count}} heures",
|
||||
"days_ago": "Il y a {{count}} jour",
|
||||
"days_ago_plural": "Il y a {{count}} jours"
|
||||
},
|
||||
"language": {
|
||||
"title": "Langue",
|
||||
"english": "English",
|
||||
"french": "Français"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"back_to_mail": "Retour aux emails",
|
||||
"save_success": "Paramètres enregistrés avec succès",
|
||||
"import_success": "Paramètres importés avec succès",
|
||||
"import_error": "Échec de l'importation des paramètres",
|
||||
"reset_confirm": "Êtes-vous sûr de vouloir réinitialiser tous les paramètres ?",
|
||||
"tabs": {
|
||||
"appearance": "Apparence",
|
||||
"language": "Langue et région",
|
||||
"email": "Comportement email",
|
||||
"composer": "Compositeur",
|
||||
"privacy": "Confidentialité et sécurité",
|
||||
"account": "Compte",
|
||||
"advanced": "Avancé"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
"description": "Personnalisez l'apparence de votre webmail",
|
||||
"theme": {
|
||||
"label": "Thème",
|
||||
"description": "Choisissez votre schéma de couleurs préféré",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système"
|
||||
},
|
||||
"font_size": {
|
||||
"label": "Taille de police",
|
||||
"description": "Ajustez la taille du texte pour une meilleure lisibilité",
|
||||
"small": "Petite",
|
||||
"medium": "Moyenne",
|
||||
"large": "Grande"
|
||||
},
|
||||
"list_density": {
|
||||
"label": "Densité de la liste",
|
||||
"description": "Contrôlez l'espacement dans les listes d'emails",
|
||||
"compact": "Compacte",
|
||||
"regular": "Normale",
|
||||
"comfortable": "Confortable"
|
||||
},
|
||||
"animations": {
|
||||
"label": "Activer les animations",
|
||||
"description": "Afficher les transitions et effets fluides"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Langue et région",
|
||||
"description": "Configurez vos préférences linguistiques et régionales",
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"description": "Choisissez votre langue préférée",
|
||||
"english": "English",
|
||||
"french": "Français"
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Format de date",
|
||||
"description": "Comment les dates doivent être affichées",
|
||||
"regional": "Régional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Format d'heure",
|
||||
"description": "Choisissez entre 12 heures ou 24 heures",
|
||||
"12h": "12 heures",
|
||||
"24h": "24 heures"
|
||||
},
|
||||
"first_day": {
|
||||
"label": "Premier jour de la semaine",
|
||||
"description": "Commencer la semaine le dimanche ou le lundi",
|
||||
"sunday": "Dimanche",
|
||||
"monday": "Lundi"
|
||||
}
|
||||
},
|
||||
"email_behavior": {
|
||||
"title": "Comportement email",
|
||||
"description": "Configurez la gestion des emails",
|
||||
"mark_read": {
|
||||
"label": "Marquer comme lu",
|
||||
"description": "Quand marquer les emails comme lus à l'ouverture",
|
||||
"instant": "Instantanément",
|
||||
"delay_3s": "Après 3 secondes",
|
||||
"delay_5s": "Après 5 secondes",
|
||||
"never": "Jamais"
|
||||
},
|
||||
"delete_action": {
|
||||
"label": "Action de suppression",
|
||||
"description": "Que se passe-t-il quand vous supprimez un email",
|
||||
"trash": "Déplacer vers la corbeille",
|
||||
"permanent": "Supprimer définitivement"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Afficher l'aperçu",
|
||||
"description": "Afficher l'aperçu de l'email dans la liste"
|
||||
},
|
||||
"emails_per_page": {
|
||||
"label": "Emails par page",
|
||||
"description": "Nombre d'emails à charger à la fois",
|
||||
"25": "25 emails",
|
||||
"50": "50 emails",
|
||||
"100": "100 emails"
|
||||
},
|
||||
"external_content": {
|
||||
"label": "Contenu externe",
|
||||
"description": "Comment gérer les images et le contenu externe",
|
||||
"ask": "Toujours demander",
|
||||
"block": "Toujours bloquer",
|
||||
"allow": "Toujours autoriser"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
"title": "Compositeur",
|
||||
"description": "Configurez les paramètres de composition d'email",
|
||||
"autosave": {
|
||||
"label": "Intervalle de sauvegarde automatique",
|
||||
"description": "Fréquence de sauvegarde automatique des brouillons",
|
||||
"30s": "Toutes les 30 secondes",
|
||||
"1m": "Toutes les minutes",
|
||||
"2m": "Toutes les 2 minutes",
|
||||
"5m": "Toutes les 5 minutes"
|
||||
},
|
||||
"send_confirmation": {
|
||||
"label": "Confirmation d'envoi",
|
||||
"description": "Demander une confirmation avant d'envoyer les emails"
|
||||
},
|
||||
"default_reply": {
|
||||
"label": "Mode de réponse par défaut",
|
||||
"description": "Action par défaut lors du clic sur répondre",
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous"
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Confidentialité et sécurité",
|
||||
"description": "Gérez vos paramètres de confidentialité et sécurité",
|
||||
"external_images": {
|
||||
"label": "Bloquer les images externes",
|
||||
"description": "Empêcher le suivi par les images externes"
|
||||
},
|
||||
"session_timeout": {
|
||||
"label": "Délai d'expiration de session",
|
||||
"description": "Déconnexion automatique après inactivité",
|
||||
"never": "Jamais",
|
||||
"30m": "30 minutes",
|
||||
"1h": "1 heure",
|
||||
"4h": "4 heures"
|
||||
},
|
||||
"clear_cache": {
|
||||
"label": "Vider le cache",
|
||||
"description": "Supprimer les données en cache et fichiers temporaires",
|
||||
"button": "Vider le cache",
|
||||
"confirm": "Êtes-vous sûr de vouloir vider le cache ?",
|
||||
"success": "Cache vidé avec succès"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"title": "Compte",
|
||||
"description": "Consultez les informations de votre compte",
|
||||
"email": {
|
||||
"label": "Adresse email",
|
||||
"value": "{{email}}"
|
||||
},
|
||||
"server": {
|
||||
"label": "Serveur JMAP",
|
||||
"value": "{{server}}"
|
||||
},
|
||||
"storage": {
|
||||
"label": "Utilisation du stockage",
|
||||
"used": "{{used}} sur {{total}} utilisés",
|
||||
"percentage": "{{percent}}% utilisé"
|
||||
},
|
||||
"last_sync": {
|
||||
"label": "Dernière synchronisation",
|
||||
"value": "{{time}}"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avancé",
|
||||
"description": "Options avancées et paramètres développeur",
|
||||
"debug_mode": {
|
||||
"label": "Mode débogage",
|
||||
"description": "Activer la journalisation détaillée pour le dépannage"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"label": "Raccourcis clavier",
|
||||
"description": "Voir les raccourcis clavier disponibles",
|
||||
"button": "Voir les raccourcis"
|
||||
},
|
||||
"reset_settings": {
|
||||
"label": "Réinitialiser les paramètres",
|
||||
"description": "Restaurer tous les paramètres par défaut",
|
||||
"button": "Réinitialiser"
|
||||
},
|
||||
"export_settings": {
|
||||
"label": "Exporter les paramètres",
|
||||
"description": "Télécharger vos paramètres au format JSON",
|
||||
"button": "Exporter"
|
||||
},
|
||||
"import_settings": {
|
||||
"label": "Importer les paramètres",
|
||||
"description": "Charger les paramètres depuis un fichier JSON",
|
||||
"button": "Importer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"page_error_title": "Une erreur s'est produite",
|
||||
"page_error_description": "Nous avons rencontré une erreur inattendue. Veuillez réessayer ou retourner à la page d'accueil.",
|
||||
"sidebar_error": "Impossible de charger les boîtes mail",
|
||||
"email_list_error": "Impossible de charger les emails",
|
||||
"viewer_error_title": "Impossible d'afficher l'email",
|
||||
"viewer_error_description": "Un problème est survenu lors de l'affichage de cet email. Il peut contenir du contenu non pris en charge.",
|
||||
"composer_error": "Impossible de charger le compositeur",
|
||||
"settings_error_title": "Paramètres indisponibles",
|
||||
"settings_error_description": "Impossible de charger les paramètres. Vos préférences peuvent ne pas être sauvegardées.",
|
||||
"try_again": "Réessayer",
|
||||
"reload": "Recharger",
|
||||
"reload_emails": "Recharger les emails",
|
||||
"reload_settings": "Recharger les paramètres",
|
||||
"retry": "Réessayer",
|
||||
"go_home": "Aller à la boîte de réception"
|
||||
},
|
||||
"context_menu": {
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous",
|
||||
"forward": "Transférer",
|
||||
"mark_read": "Marquer comme lu",
|
||||
"mark_unread": "Marquer comme non lu",
|
||||
"star": "Marquer comme favori",
|
||||
"unstar": "Retirer des favoris",
|
||||
"move_to": "Déplacer vers...",
|
||||
"archive": "Archiver",
|
||||
"delete": "Supprimer",
|
||||
"color_tag": "Étiquette de couleur",
|
||||
"remove_color": "Supprimer la couleur",
|
||||
"items_selected": "{{count}} emails sélectionnés"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Raccourcis clavier",
|
||||
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
|
||||
"sections": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Actions email",
|
||||
"global": "Global",
|
||||
"threads": "Conversations"
|
||||
},
|
||||
"navigation": {
|
||||
"next_email": "Email suivant",
|
||||
"previous_email": "Email précédent",
|
||||
"open_email": "Ouvrir l'email",
|
||||
"close_email": "Fermer / Désélectionner"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous",
|
||||
"forward": "Transférer",
|
||||
"star": "Basculer favori",
|
||||
"archive": "Archiver",
|
||||
"delete": "Supprimer",
|
||||
"mark_unread": "Marquer comme non lu",
|
||||
"mark_read": "Marquer comme lu"
|
||||
},
|
||||
"global": {
|
||||
"compose": "Composer un email",
|
||||
"search": "Rechercher",
|
||||
"help": "Afficher les raccourcis",
|
||||
"refresh": "Actualiser les emails",
|
||||
"select_all": "Tout sélectionner"
|
||||
},
|
||||
"threads": {
|
||||
"expand_collapse": "Développer/réduire la conversation"
|
||||
}
|
||||
},
|
||||
"threads": {
|
||||
"messages_one": "{count} message",
|
||||
"messages_other": "{count} messages",
|
||||
"expand": "Développer la conversation",
|
||||
"collapse": "Réduire la conversation",
|
||||
"loading": "Chargement de la conversation...",
|
||||
"mark_read": "Marquer la conversation comme lue",
|
||||
"mark_unread": "Marquer la conversation comme non lue",
|
||||
"archive": "Archiver la conversation",
|
||||
"delete": "Supprimer la conversation",
|
||||
"star": "Marquer la conversation comme favorite",
|
||||
"unstar": "Retirer des favoris"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const createNextIntlPlugin = require('next-intl/plugin');
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
module.exports = withNextIntl(nextConfig);
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7821
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "jmap-webmail",
|
||||
"version": "1.0.0",
|
||||
"description": "A modern JMAP webmail client built for Stalwart Mail Server",
|
||||
"author": "Matthieu MALVACHE <matthieu@root.cloud>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/root-fr/jmap-webmail.git"
|
||||
},
|
||||
"homepage": "https://github.com/root-fr/jmap-webmail",
|
||||
"bugs": {
|
||||
"url": "https://github.com/root-fr/jmap-webmail/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"jmap",
|
||||
"webmail",
|
||||
"stalwart",
|
||||
"email",
|
||||
"nextjs",
|
||||
"react",
|
||||
"typescript"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"prepare": "husky",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"jmap-jam": "^0.11.0",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "^16.0.8",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-intl": "^4.5.8",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.8",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { locales, defaultLocale } from './i18n/request';
|
||||
|
||||
export default createMiddleware({
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'always', // Always show locale in URL for consistency
|
||||
localeDetection: true // Enable browser language detection
|
||||
});
|
||||
|
||||
export const config = {
|
||||
// Skip all paths that should not be internationalized
|
||||
matcher: ['/((?!api|_next|.*\\..*).*)']
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,141 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { useEmailStore } from './email-store';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
serverUrl: string | null;
|
||||
username: string | null;
|
||||
client: JMAPClient | null;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
client: null,
|
||||
|
||||
login: async (serverUrl, username, password) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
// Create JMAP client
|
||||
const client = new JMAPClient(serverUrl, username, password);
|
||||
|
||||
// Try to connect
|
||||
await client.connect();
|
||||
|
||||
// Success - save state (but NOT the password)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl,
|
||||
username,
|
||||
client,
|
||||
error: null,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
let errorKey = 'generic';
|
||||
|
||||
// Map common errors to translation keys
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Invalid username or password') ||
|
||||
error.message.includes('401') ||
|
||||
error.message.includes('Unauthorized')) {
|
||||
errorKey = 'invalid_credentials';
|
||||
} else if (error.message.includes('network') ||
|
||||
error.message.includes('Failed to fetch')) {
|
||||
errorKey = 'connection_failed';
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorKey,
|
||||
isAuthenticated: false,
|
||||
client: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
const state = get();
|
||||
|
||||
// Disconnect the JMAP client if it exists
|
||||
if (state.client) {
|
||||
state.client.disconnect();
|
||||
}
|
||||
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
client: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Clear persisted storage
|
||||
localStorage.removeItem('auth-storage');
|
||||
|
||||
// Clear email store state
|
||||
useEmailStore.setState({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
selectedEmail: null,
|
||||
selectedMailbox: "",
|
||||
isLoading: false,
|
||||
error: null,
|
||||
searchQuery: "",
|
||||
quota: null,
|
||||
});
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const state = get();
|
||||
|
||||
// If authenticated but no client (e.g., after page refresh), we can't restore the session
|
||||
// because we don't store passwords for security reasons
|
||||
if (state.isAuthenticated && !state.client) {
|
||||
// Reset auth state - user will need to log in again
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
client: null,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Mark loading as complete
|
||||
set({ isLoading: false });
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
// Only persist non-sensitive data
|
||||
serverUrl: state.serverUrl,
|
||||
username: state.username,
|
||||
// Don't persist isAuthenticated since we can't restore the session without a password
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,984 @@
|
||||
import { create } from "zustand";
|
||||
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
||||
import { JMAPClient } from "@/lib/jmap/client";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface EmailStore {
|
||||
emails: Email[];
|
||||
mailboxes: Mailbox[];
|
||||
selectedEmail: Email | null;
|
||||
selectedMailbox: string;
|
||||
isLoading: boolean;
|
||||
isLoadingEmail: boolean; // Track when a full email is being fetched
|
||||
isLoadingMore: boolean; // Track when loading more emails (pagination)
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
quota: { used: number; total: number } | null;
|
||||
processingReadStatus: Set<string>; // Track emails being marked as read/unread
|
||||
selectedEmailIds: Set<string>; // Track selected emails for batch operations
|
||||
hasMoreEmails: boolean; // Track if more emails are available to load
|
||||
totalEmails: number; // Total number of emails in the current mailbox/query
|
||||
isPushConnected: boolean; // Track if push notifications are connected
|
||||
lastPushUpdate: number | null; // Timestamp of last push update
|
||||
newEmailNotification: Email | null; // New email notification for toast
|
||||
|
||||
// Thread expansion state
|
||||
expandedThreadIds: Set<string>; // Which threads are expanded in the list
|
||||
threadEmailsCache: Map<string, Email[]>; // Cache of fully fetched thread emails
|
||||
isLoadingThread: string | null; // Thread ID currently being loaded
|
||||
|
||||
setEmails: (emails: Email[]) => void;
|
||||
setMailboxes: (mailboxes: Mailbox[]) => void;
|
||||
selectEmail: (email: Email | null) => void;
|
||||
selectMailbox: (mailboxId: string) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setLoadingEmail: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setQuota: (quota: { used: number; total: number } | null) => void;
|
||||
toggleEmailSelection: (emailId: string) => void;
|
||||
selectAllEmails: () => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
// JMAP operations
|
||||
fetchMailboxes: (client: JMAPClient) => Promise<void>;
|
||||
fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise<void>;
|
||||
loadMoreEmails: (client: JMAPClient) => Promise<void>;
|
||||
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: JMAPClient) => Promise<void>;
|
||||
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], draftId?: string) => Promise<void>;
|
||||
deleteEmail: (client: JMAPClient, emailId: string) => Promise<void>;
|
||||
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
|
||||
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
|
||||
|
||||
// Batch operations
|
||||
batchMarkAsRead: (client: JMAPClient, read: boolean) => Promise<void>;
|
||||
batchDelete: (client: JMAPClient) => Promise<void>;
|
||||
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
|
||||
|
||||
// Push notification handlers
|
||||
setPushConnected: (connected: boolean) => void;
|
||||
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
|
||||
refreshCurrentMailbox: (client: JMAPClient) => Promise<void>;
|
||||
handleNewEmailNotification: (email: Email) => void;
|
||||
clearNewEmailNotification: () => void;
|
||||
|
||||
// Thread expansion actions
|
||||
toggleThreadExpansion: (threadId: string) => void;
|
||||
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>;
|
||||
collapseAllThreads: () => void;
|
||||
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
||||
|
||||
// Mock data for demo
|
||||
loadMockData: () => void;
|
||||
}
|
||||
|
||||
export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
selectedEmail: null,
|
||||
selectedMailbox: "",
|
||||
isLoading: false,
|
||||
isLoadingEmail: false,
|
||||
isLoadingMore: false,
|
||||
error: null,
|
||||
searchQuery: "",
|
||||
quota: null,
|
||||
processingReadStatus: new Set(),
|
||||
selectedEmailIds: new Set(),
|
||||
hasMoreEmails: false,
|
||||
totalEmails: 0,
|
||||
isPushConnected: false,
|
||||
lastPushUpdate: null,
|
||||
newEmailNotification: null,
|
||||
|
||||
// Thread expansion state
|
||||
expandedThreadIds: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
isLoadingThread: null,
|
||||
|
||||
setEmails: (emails) => set({ emails }),
|
||||
setMailboxes: (mailboxes) => set({ mailboxes }),
|
||||
selectEmail: (email) => set({ selectedEmail: email }),
|
||||
selectMailbox: (mailboxId) => set({
|
||||
selectedMailbox: mailboxId,
|
||||
selectedEmail: null,
|
||||
selectedEmailIds: new Set(),
|
||||
expandedThreadIds: new Set(),
|
||||
threadEmailsCache: new Map(),
|
||||
isLoadingThread: null,
|
||||
}),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setLoadingEmail: (loading) => set({ isLoadingEmail: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setQuota: (quota) => set({ quota }),
|
||||
|
||||
toggleEmailSelection: (emailId) => {
|
||||
const { selectedEmailIds } = get();
|
||||
const newSelection = new Set(selectedEmailIds);
|
||||
if (newSelection.has(emailId)) {
|
||||
newSelection.delete(emailId);
|
||||
} else {
|
||||
newSelection.add(emailId);
|
||||
}
|
||||
set({ selectedEmailIds: newSelection });
|
||||
},
|
||||
|
||||
selectAllEmails: () => {
|
||||
const { emails } = get();
|
||||
const allIds = new Set(emails.map(e => e.id));
|
||||
set({ selectedEmailIds: allIds });
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedEmailIds: new Set() });
|
||||
},
|
||||
|
||||
// JMAP operations
|
||||
fetchMailboxes: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const mailboxes = await client.getAllMailboxes();
|
||||
|
||||
// Auto-select inbox if no mailbox is currently selected
|
||||
const currentSelectedMailbox = get().selectedMailbox;
|
||||
if (!currentSelectedMailbox) {
|
||||
// Find inbox from PRIMARY account (not shared accounts)
|
||||
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
|
||||
if (inboxMailbox) {
|
||||
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
|
||||
} else {
|
||||
set({ mailboxes, isLoading: false });
|
||||
}
|
||||
} else {
|
||||
set({ mailboxes, isLoading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchEmails: async (client, mailboxId) => {
|
||||
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
|
||||
try {
|
||||
const targetMailboxId = mailboxId || get().selectedMailbox;
|
||||
|
||||
// Find the mailbox to get its accountId (for shared folder support)
|
||||
const mailboxes = get().mailboxes;
|
||||
const mailbox = mailboxes.find(mb => mb.id === targetMailboxId);
|
||||
// Only pass accountId for shared mailboxes, not for primary account
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||
const jmapMailboxId = mailbox?.originalId || targetMailboxId;
|
||||
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
||||
set({
|
||||
emails: result.emails,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch emails:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to fetch emails",
|
||||
isLoading: false,
|
||||
emails: [],
|
||||
hasMoreEmails: false,
|
||||
totalEmails: 0
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadMoreEmails: async (client) => {
|
||||
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox } = get();
|
||||
|
||||
// Don't load if already loading or no more emails
|
||||
if (isLoadingMore || !hasMoreEmails) return;
|
||||
|
||||
set({ isLoadingMore: true, error: null });
|
||||
try {
|
||||
// Find the mailbox to get its accountId (for shared folder support)
|
||||
const mailboxes = get().mailboxes;
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
// Only pass accountId for shared mailboxes, not for primary account
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
|
||||
|
||||
set({
|
||||
emails: [...emails, ...result.emails],
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoadingMore: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load more emails:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to load more emails",
|
||||
isLoadingMore: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchEmailContent: async (client, emailId) => {
|
||||
try {
|
||||
// Find the selected mailbox to determine accountId (for shared folders)
|
||||
const selectedMailboxId = get().selectedMailbox;
|
||||
const mailboxes = get().mailboxes;
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
|
||||
|
||||
// Only pass accountId for shared mailboxes
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
const email = await client.getEmail(emailId, accountId);
|
||||
|
||||
if (email) {
|
||||
set({ selectedEmail: email });
|
||||
}
|
||||
return email;
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to fetch email content"
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
fetchQuota: async (client) => {
|
||||
try {
|
||||
const quota = await client.getQuota();
|
||||
set({ quota });
|
||||
} catch {
|
||||
// Don't set error state as quota is optional
|
||||
}
|
||||
},
|
||||
|
||||
sendEmail: async (client, to, subject, body, cc, bcc, draftId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await client.sendEmail(to, subject, body, cc, bcc, draftId);
|
||||
// Refresh emails after sending
|
||||
await get().fetchEmails(client);
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
isLoading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteEmail: async (client, emailId) => {
|
||||
try {
|
||||
// Get the email to check if it's unread and which mailboxes it belongs to
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
|
||||
// Get delete action preference from settings
|
||||
const deleteAction = useSettingsStore.getState().deleteAction;
|
||||
|
||||
// Determine accountId for shared folders
|
||||
const selectedMailboxId = get().selectedMailbox;
|
||||
const mailboxes = get().mailboxes;
|
||||
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
|
||||
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||||
|
||||
// If deleteAction is 'trash', try to move to trash mailbox
|
||||
if (deleteAction === 'trash') {
|
||||
// Find trash mailbox for the correct account
|
||||
const trashMailbox = mailboxes.find(mb => {
|
||||
if (accountId) {
|
||||
// For shared folders, match by accountId
|
||||
return mb.role === 'trash' && mb.accountId === accountId;
|
||||
}
|
||||
// For primary account, find trash that's not from a shared folder
|
||||
return mb.role === 'trash' && !mb.isShared;
|
||||
});
|
||||
|
||||
if (trashMailbox) {
|
||||
// Use originalId for shared mailboxes if available
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await client.moveToTrash(emailId, trashId, accountId);
|
||||
|
||||
// Remove from local state (email moved to trash, not in current view)
|
||||
set((state) => {
|
||||
let updatedMailboxes = state.mailboxes;
|
||||
|
||||
// Update counters for source mailbox (email leaving)
|
||||
if (email.mailboxIds) {
|
||||
updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (email.mailboxIds[mailbox.id]) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
|
||||
totalThreads: Math.max(0, mailbox.totalThreads - 1),
|
||||
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
// Update trash mailbox counters (email arriving)
|
||||
if (mailbox.id === trashMailbox.id) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: mailbox.totalEmails + 1,
|
||||
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
totalThreads: mailbox.totalThreads + 1,
|
||||
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
emails: state.emails.filter(e => e.id !== emailId),
|
||||
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
|
||||
mailboxes: updatedMailboxes
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If no trash mailbox found, fall through to permanent delete
|
||||
}
|
||||
|
||||
// Permanent delete
|
||||
await client.deleteEmail(emailId);
|
||||
|
||||
// Remove from local state and update mailbox counters if needed
|
||||
set((state) => {
|
||||
let updatedMailboxes = state.mailboxes;
|
||||
|
||||
// If the email was unread, decrement the unread counters
|
||||
if (isUnread && email.mailboxIds) {
|
||||
updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (email.mailboxIds[mailbox.id]) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
unreadEmails: Math.max(0, mailbox.unreadEmails - 1),
|
||||
totalThreads: Math.max(0, mailbox.totalThreads - 1),
|
||||
unreadThreads: Math.max(0, mailbox.unreadThreads - 1)
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
} else if (email.mailboxIds) {
|
||||
// If email was read, only decrement total counters
|
||||
updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (email.mailboxIds[mailbox.id]) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
totalThreads: Math.max(0, mailbox.totalThreads - 1)
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
emails: state.emails.filter(e => e.id !== emailId),
|
||||
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
|
||||
mailboxes: updatedMailboxes
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to delete email"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
markAsRead: async (client, emailId, read) => {
|
||||
try {
|
||||
// Check if this email is already being processed
|
||||
const processingKey = `${emailId}-${read}`;
|
||||
const currentProcessing = get().processingReadStatus;
|
||||
if (currentProcessing.has(processingKey)) {
|
||||
return; // Already being processed
|
||||
}
|
||||
|
||||
// Get the email to check its current state and mailboxes
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
// Check if already in the desired state
|
||||
const isCurrentlyRead = email.keywords?.$seen === true;
|
||||
if (isCurrentlyRead === read) {
|
||||
return; // Already in desired state
|
||||
}
|
||||
|
||||
// Add to processing set
|
||||
set((state) => ({
|
||||
processingReadStatus: new Set([...state.processingReadStatus, processingKey])
|
||||
}));
|
||||
|
||||
// Determine accountId for shared folders
|
||||
const selectedMailboxId = get().selectedMailbox;
|
||||
const mailboxes = get().mailboxes;
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
await client.markAsRead(emailId, read, accountId);
|
||||
|
||||
// Update local state including mailbox counters
|
||||
set((state) => {
|
||||
// Remove from processing set
|
||||
const newProcessingSet = new Set(state.processingReadStatus);
|
||||
newProcessingSet.delete(processingKey);
|
||||
|
||||
// Only update counters if the state is actually changing
|
||||
const emailInState = state.emails.find(e => e.id === emailId);
|
||||
if (!emailInState) return { processingReadStatus: newProcessingSet };
|
||||
|
||||
const wasRead = emailInState.keywords?.$seen === true;
|
||||
if (wasRead === read) {
|
||||
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
|
||||
}
|
||||
|
||||
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
// Check if this email belongs to this mailbox
|
||||
if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) {
|
||||
// Adjust unread counter: -1 if marking as read, +1 if marking as unread
|
||||
const delta = read ? -1 : 1;
|
||||
return {
|
||||
...mailbox,
|
||||
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
|
||||
unreadThreads: Math.max(0, mailbox.unreadThreads + delta)
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
|
||||
return {
|
||||
emails: state.emails.map(e =>
|
||||
e.id === emailId ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
|
||||
),
|
||||
selectedEmail: state.selectedEmail?.id === emailId
|
||||
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
|
||||
: state.selectedEmail,
|
||||
mailboxes: updatedMailboxes,
|
||||
processingReadStatus: newProcessingSet
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// Remove from processing set on error
|
||||
set((state) => {
|
||||
const newProcessingSet = new Set(state.processingReadStatus);
|
||||
newProcessingSet.delete(`${emailId}-${read}`);
|
||||
return {
|
||||
processingReadStatus: newProcessingSet,
|
||||
error: error instanceof Error ? error.message : "Failed to update email"
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
moveToMailbox: async (client, emailId, destinationMailboxId) => {
|
||||
try {
|
||||
// Get the email to check its current mailboxes and read status
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
|
||||
|
||||
await client.moveEmail(emailId, destinationMailboxId);
|
||||
|
||||
// Update local state and mailbox counters
|
||||
set((state) => {
|
||||
// Update mailbox counters
|
||||
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
// Remove from current mailboxes
|
||||
if (currentMailboxIds.includes(mailbox.id)) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
|
||||
totalThreads: Math.max(0, mailbox.totalThreads - 1),
|
||||
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
// Add to destination mailbox
|
||||
else if (mailbox.id === destinationMailboxId) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: mailbox.totalEmails + 1,
|
||||
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
totalThreads: mailbox.totalThreads + 1,
|
||||
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
|
||||
// Update the email's mailboxIds
|
||||
const updatedEmails = state.emails.map(e => {
|
||||
if (e.id === emailId) {
|
||||
return {
|
||||
...e,
|
||||
mailboxIds: { [destinationMailboxId]: true }
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
// If moved email is selected, update selectedEmail too
|
||||
const updatedSelectedEmail = state.selectedEmail?.id === emailId
|
||||
? { ...state.selectedEmail, mailboxIds: { [destinationMailboxId]: true } }
|
||||
: state.selectedEmail;
|
||||
|
||||
return {
|
||||
emails: updatedEmails,
|
||||
selectedEmail: updatedSelectedEmail,
|
||||
mailboxes: updatedMailboxes
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to move email"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
searchEmails: async (client, query) => {
|
||||
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
|
||||
try {
|
||||
const emails = await client.searchEmails(query);
|
||||
set({ emails, isLoading: false, hasMoreEmails: false, totalEmails: emails.length });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||
isLoading: false,
|
||||
emails: [],
|
||||
hasMoreEmails: false,
|
||||
totalEmails: 0
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
toggleStar: async (client, emailId) => {
|
||||
try {
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const isFlagged = email.keywords.$flagged || false;
|
||||
await client.toggleStar(emailId, !isFlagged);
|
||||
|
||||
// Update local state
|
||||
set((state) => ({
|
||||
emails: state.emails.map(e =>
|
||||
e.id === emailId ? { ...e, keywords: { ...e.keywords, $flagged: !isFlagged } } : e
|
||||
),
|
||||
selectedEmail: state.selectedEmail?.id === emailId
|
||||
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $flagged: !isFlagged } }
|
||||
: state.selectedEmail
|
||||
}));
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to update star"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Batch operations
|
||||
batchMarkAsRead: async (client, read) => {
|
||||
const { selectedEmailIds, emails, mailboxes } = get();
|
||||
if (selectedEmailIds.size === 0) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const emailIdsArray = Array.from(selectedEmailIds);
|
||||
await client.batchMarkAsRead(emailIdsArray, read);
|
||||
|
||||
// Update local state
|
||||
const updatedEmails = emails.map(email =>
|
||||
selectedEmailIds.has(email.id)
|
||||
? { ...email, keywords: { ...email.keywords, $seen: read } }
|
||||
: email
|
||||
);
|
||||
|
||||
// Update mailbox counters
|
||||
const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id));
|
||||
const updatedMailboxes = mailboxes.map(mailbox => {
|
||||
let deltaUnread = 0;
|
||||
affectedEmails.forEach(email => {
|
||||
if (email.mailboxIds?.[mailbox.id]) {
|
||||
const wasRead = email.keywords?.$seen === true;
|
||||
if (wasRead !== read) {
|
||||
deltaUnread += read ? -1 : 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...mailbox,
|
||||
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread),
|
||||
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread)
|
||||
};
|
||||
});
|
||||
|
||||
set({
|
||||
emails: updatedEmails,
|
||||
mailboxes: updatedMailboxes,
|
||||
selectedEmailIds: new Set(),
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to update emails",
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
batchDelete: async (client) => {
|
||||
const { selectedEmailIds, emails, mailboxes } = get();
|
||||
if (selectedEmailIds.size === 0) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const emailIdsArray = Array.from(selectedEmailIds);
|
||||
await client.batchDeleteEmails(emailIdsArray);
|
||||
|
||||
// Remove deleted emails from local state
|
||||
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
|
||||
|
||||
// Update mailbox counters
|
||||
const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id));
|
||||
const updatedMailboxes = mailboxes.map(mailbox => {
|
||||
let deltaTotalEmails = 0;
|
||||
let deltaUnreadEmails = 0;
|
||||
|
||||
deletedEmails.forEach(email => {
|
||||
if (email.mailboxIds?.[mailbox.id]) {
|
||||
deltaTotalEmails--;
|
||||
if (!email.keywords?.$seen) {
|
||||
deltaUnreadEmails--;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
|
||||
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
|
||||
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
|
||||
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails)
|
||||
};
|
||||
});
|
||||
|
||||
set({
|
||||
emails: remainingEmails,
|
||||
mailboxes: updatedMailboxes,
|
||||
selectedEmailIds: new Set(),
|
||||
selectedEmail: null,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to delete emails",
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
batchMoveToMailbox: async (client, toMailboxId) => {
|
||||
const { selectedEmailIds, emails } = get();
|
||||
if (selectedEmailIds.size === 0) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const emailIdsArray = Array.from(selectedEmailIds);
|
||||
await client.batchMoveEmails(emailIdsArray, toMailboxId);
|
||||
|
||||
// Update local state - remove from current view since they moved
|
||||
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
|
||||
|
||||
set({
|
||||
emails: remainingEmails,
|
||||
selectedEmailIds: new Set(),
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
// Refresh emails to get updated list
|
||||
await get().fetchEmails(client, get().selectedMailbox);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to move emails",
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Push notification handlers
|
||||
setPushConnected: (connected) => {
|
||||
set({ isPushConnected: connected });
|
||||
},
|
||||
|
||||
handleStateChange: async (change, client) => {
|
||||
try {
|
||||
// Update last push update timestamp
|
||||
set({ lastPushUpdate: Date.now() });
|
||||
|
||||
// Get the current account ID from the client (assuming primary account)
|
||||
const accountId = client.getAccountId();
|
||||
|
||||
// Check if there are changes for this account
|
||||
const accountChanges = change.changed[accountId];
|
||||
if (!accountChanges) return;
|
||||
|
||||
// Handle Email state changes - refresh current mailbox
|
||||
if (accountChanges.Email) {
|
||||
await get().refreshCurrentMailbox(client);
|
||||
}
|
||||
|
||||
// Handle Mailbox state changes - refresh mailbox list
|
||||
if (accountChanges.Mailbox) {
|
||||
await get().fetchMailboxes(client);
|
||||
}
|
||||
|
||||
// Could also handle Thread, EmailSubmission, Identity changes in the future
|
||||
} catch (error) {
|
||||
console.error('Failed to handle state change:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to handle push notification"
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
refreshCurrentMailbox: async (client) => {
|
||||
const { selectedMailbox } = get();
|
||||
|
||||
// Only refresh if a mailbox is currently selected
|
||||
if (!selectedMailbox) return;
|
||||
|
||||
try {
|
||||
// Fetch emails for the current mailbox without clearing the list first
|
||||
// This provides a smoother update experience
|
||||
const mailboxes = get().mailboxes;
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
||||
|
||||
// Check if there are new emails by comparing the first email ID
|
||||
const currentFirstEmailId = get().emails[0]?.id;
|
||||
const newFirstEmailId = result.emails[0]?.id;
|
||||
|
||||
// If the first email changed, we have a new email - trigger notification
|
||||
if (currentFirstEmailId !== newFirstEmailId && result.emails[0]) {
|
||||
get().handleNewEmailNotification(result.emails[0]);
|
||||
}
|
||||
|
||||
set({
|
||||
emails: result.emails,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh current mailbox:', error);
|
||||
// Don't set error state for background refreshes to avoid disrupting the UI
|
||||
}
|
||||
},
|
||||
|
||||
handleNewEmailNotification: (email) => {
|
||||
// Set the new email notification state
|
||||
// This can be consumed by a toast component
|
||||
set({ newEmailNotification: email });
|
||||
},
|
||||
|
||||
clearNewEmailNotification: () => {
|
||||
set({ newEmailNotification: null });
|
||||
},
|
||||
|
||||
// Thread expansion actions
|
||||
toggleThreadExpansion: (threadId) => {
|
||||
const { expandedThreadIds } = get();
|
||||
const newExpandedThreadIds = new Set(expandedThreadIds);
|
||||
|
||||
if (newExpandedThreadIds.has(threadId)) {
|
||||
newExpandedThreadIds.delete(threadId);
|
||||
} else {
|
||||
newExpandedThreadIds.add(threadId);
|
||||
}
|
||||
|
||||
set({ expandedThreadIds: newExpandedThreadIds });
|
||||
},
|
||||
|
||||
fetchThreadEmails: async (client, threadId) => {
|
||||
const { threadEmailsCache, selectedMailbox, mailboxes } = get();
|
||||
|
||||
// Check if we already have this thread cached
|
||||
const cachedEmails = threadEmailsCache.get(threadId);
|
||||
if (cachedEmails && cachedEmails.length > 0) {
|
||||
return cachedEmails;
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
set({ isLoadingThread: threadId });
|
||||
|
||||
try {
|
||||
// Determine accountId for shared folders
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
// Fetch all emails in the thread
|
||||
const emails = await client.getThreadEmails(threadId, accountId);
|
||||
|
||||
// Update cache
|
||||
const newCache = new Map(get().threadEmailsCache);
|
||||
newCache.set(threadId, emails);
|
||||
|
||||
set({
|
||||
threadEmailsCache: newCache,
|
||||
isLoadingThread: null
|
||||
});
|
||||
|
||||
return emails;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch thread emails:', error);
|
||||
set({ isLoadingThread: null });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
collapseAllThreads: () => {
|
||||
set({
|
||||
expandedThreadIds: new Set(),
|
||||
isLoadingThread: null
|
||||
});
|
||||
},
|
||||
|
||||
updateThreadCache: (threadId, emails) => {
|
||||
const newCache = new Map(get().threadEmailsCache);
|
||||
newCache.set(threadId, emails);
|
||||
set({ threadEmailsCache: newCache });
|
||||
},
|
||||
|
||||
loadMockData: () => {
|
||||
const mockEmails: Email[] = [
|
||||
{
|
||||
id: "1",
|
||||
threadId: "thread-1",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: false },
|
||||
size: 1024,
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [{ name: "Alice Johnson", email: "alice@example.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Q4 Budget Review Meeting",
|
||||
preview: "Hi team, I wanted to schedule a meeting to review our Q4 budget projections. Are you available this Thursday at 2 PM? We need to discuss...",
|
||||
hasAttachment: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
threadId: "thread-2",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true, $flagged: true },
|
||||
size: 512,
|
||||
receivedAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
from: [{ name: "Bob Smith", email: "bob@company.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Re: Project Timeline Update",
|
||||
preview: "Thanks for the update. The new timeline looks good to me. I've reviewed the milestones and everything seems achievable...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
threadId: "thread-3",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: false },
|
||||
size: 2048,
|
||||
receivedAt: new Date(Date.now() - 7200000).toISOString(),
|
||||
from: [{ name: "Carol White", email: "carol@design.co" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "New Design Mockups Ready",
|
||||
preview: "Hey! The new mockups for the landing page are ready for review. I've incorporated all the feedback from last week's meeting...",
|
||||
hasAttachment: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
threadId: "thread-4",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 768,
|
||||
receivedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
from: [{ name: "GitHub", email: "notifications@github.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "[PR] Feature: Add authentication module",
|
||||
preview: "A new pull request has been opened in your repository. This PR adds a comprehensive authentication module with OAuth support...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
threadId: "thread-5",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1536,
|
||||
receivedAt: new Date(Date.now() - 172800000).toISOString(),
|
||||
from: [{ name: "David Lee", email: "david@startup.io" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Investment Proposal Discussion",
|
||||
preview: "Following up on our call yesterday, I'm sending over the investment proposal we discussed. The terms are quite favorable...",
|
||||
hasAttachment: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockMailboxes: Mailbox[] = [
|
||||
{
|
||||
id: "inbox",
|
||||
name: "Inbox",
|
||||
role: "inbox",
|
||||
sortOrder: 1,
|
||||
totalEmails: 5,
|
||||
unreadEmails: 2,
|
||||
totalThreads: 5,
|
||||
unreadThreads: 2,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
isSubscribed: true,
|
||||
},
|
||||
];
|
||||
|
||||
set({
|
||||
emails: mockEmails,
|
||||
mailboxes: mockMailboxes,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface LocaleStore {
|
||||
locale: string;
|
||||
setLocale: (locale: string) => void;
|
||||
}
|
||||
|
||||
export const useLocaleStore = create<LocaleStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
locale: 'en',
|
||||
setLocale: (locale) => set({ locale }),
|
||||
}),
|
||||
{
|
||||
name: 'locale-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,213 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type FontSize = 'small' | 'medium' | 'large';
|
||||
export type ListDensity = 'compact' | 'regular' | 'comfortable';
|
||||
export type DeleteAction = 'trash' | 'permanent';
|
||||
export type ReplyMode = 'reply' | 'replyAll';
|
||||
export type DateFormat = 'regional' | 'iso' | 'custom';
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
|
||||
|
||||
interface SettingsState {
|
||||
// Appearance
|
||||
fontSize: FontSize;
|
||||
listDensity: ListDensity;
|
||||
animationsEnabled: boolean;
|
||||
|
||||
// Language & Region
|
||||
dateFormat: DateFormat;
|
||||
timeFormat: TimeFormat;
|
||||
firstDayOfWeek: FirstDayOfWeek;
|
||||
|
||||
// Email Behavior
|
||||
markAsReadDelay: number; // milliseconds (0 = instant, -1 = never)
|
||||
deleteAction: DeleteAction;
|
||||
showPreview: boolean;
|
||||
emailsPerPage: number;
|
||||
externalContentPolicy: ExternalContentPolicy;
|
||||
|
||||
// Composer
|
||||
autoSaveDraftInterval: number; // milliseconds
|
||||
sendConfirmation: boolean;
|
||||
defaultReplyMode: ReplyMode;
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
|
||||
// Actions
|
||||
updateSetting: <K extends keyof SettingsState>(
|
||||
key: K,
|
||||
value: SettingsState[K]
|
||||
) => void;
|
||||
resetToDefaults: () => void;
|
||||
exportSettings: () => string;
|
||||
importSettings: (json: string) => boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
// Appearance
|
||||
fontSize: 'medium' as FontSize,
|
||||
listDensity: 'regular' as ListDensity,
|
||||
animationsEnabled: true,
|
||||
|
||||
// Language & Region
|
||||
dateFormat: 'regional' as DateFormat,
|
||||
timeFormat: '24h' as TimeFormat,
|
||||
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
|
||||
|
||||
// Email Behavior
|
||||
markAsReadDelay: 0, // Instant
|
||||
deleteAction: 'trash' as DeleteAction,
|
||||
showPreview: true,
|
||||
emailsPerPage: 50,
|
||||
externalContentPolicy: 'ask' as ExternalContentPolicy,
|
||||
|
||||
// Composer
|
||||
autoSaveDraftInterval: 60000, // 1 minute
|
||||
sendConfirmation: false,
|
||||
defaultReplyMode: 'reply' as ReplyMode,
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: 0, // Never
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
|
||||
updateSetting: (key, value) => {
|
||||
set({ [key]: value });
|
||||
|
||||
// Apply font size to document root
|
||||
if (key === 'fontSize') {
|
||||
applyFontSize(value as FontSize);
|
||||
}
|
||||
|
||||
// Apply list density to document root
|
||||
if (key === 'listDensity') {
|
||||
applyListDensity(value as ListDensity);
|
||||
}
|
||||
|
||||
// Apply animations to document root
|
||||
if (key === 'animationsEnabled') {
|
||||
applyAnimations(value as boolean);
|
||||
}
|
||||
},
|
||||
|
||||
resetToDefaults: () => {
|
||||
set(DEFAULT_SETTINGS);
|
||||
applyFontSize(DEFAULT_SETTINGS.fontSize);
|
||||
applyListDensity(DEFAULT_SETTINGS.listDensity);
|
||||
applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
|
||||
},
|
||||
|
||||
exportSettings: () => {
|
||||
const state = get();
|
||||
const settings = {
|
||||
fontSize: state.fontSize,
|
||||
listDensity: state.listDensity,
|
||||
animationsEnabled: state.animationsEnabled,
|
||||
dateFormat: state.dateFormat,
|
||||
timeFormat: state.timeFormat,
|
||||
firstDayOfWeek: state.firstDayOfWeek,
|
||||
markAsReadDelay: state.markAsReadDelay,
|
||||
deleteAction: state.deleteAction,
|
||||
showPreview: state.showPreview,
|
||||
emailsPerPage: state.emailsPerPage,
|
||||
externalContentPolicy: state.externalContentPolicy,
|
||||
autoSaveDraftInterval: state.autoSaveDraftInterval,
|
||||
sendConfirmation: state.sendConfirmation,
|
||||
defaultReplyMode: state.defaultReplyMode,
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
debugMode: state.debugMode,
|
||||
};
|
||||
return JSON.stringify(settings, null, 2);
|
||||
},
|
||||
|
||||
importSettings: (json: string) => {
|
||||
try {
|
||||
const settings = JSON.parse(json);
|
||||
|
||||
// Validate settings
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply settings
|
||||
Object.keys(settings).forEach((key) => {
|
||||
if (key in DEFAULT_SETTINGS) {
|
||||
set({ [key]: settings[key] });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply visual settings
|
||||
applyFontSize(get().fontSize);
|
||||
applyListDensity(get().listDensity);
|
||||
applyAnimations(get().animationsEnabled);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to import settings:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
version: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Helper functions to apply settings to DOM
|
||||
function applyFontSize(size: FontSize) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const sizeMap = {
|
||||
small: '14px',
|
||||
medium: '16px',
|
||||
large: '18px',
|
||||
};
|
||||
root.style.setProperty('--font-size-base', sizeMap[size]);
|
||||
}
|
||||
|
||||
function applyListDensity(density: ListDensity) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const densityMap = {
|
||||
compact: '32px',
|
||||
regular: '48px',
|
||||
comfortable: '64px',
|
||||
};
|
||||
root.style.setProperty('--list-item-height', densityMap[density]);
|
||||
}
|
||||
|
||||
function applyAnimations(enabled: boolean) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
if (enabled) {
|
||||
root.style.removeProperty('--transition-duration');
|
||||
} else {
|
||||
root.style.setProperty('--transition-duration', '0s');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize settings on load
|
||||
if (typeof window !== 'undefined') {
|
||||
const store = useSettingsStore.getState();
|
||||
applyFontSize(store.fontSize);
|
||||
applyListDensity(store.listDensity);
|
||||
applyAnimations(store.animationsEnabled);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
initializeTheme: () => void;
|
||||
}
|
||||
|
||||
const getSystemTheme = (): 'light' | 'dark' => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const applyTheme = (theme: 'light' | 'dark') => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
// Ensure both classes are handled properly
|
||||
if (theme === 'dark') {
|
||||
root.classList.remove('light');
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
root.classList.add('light');
|
||||
}
|
||||
|
||||
// Store in localStorage for immediate access
|
||||
localStorage.setItem('theme-applied', theme);
|
||||
};
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'system',
|
||||
resolvedTheme: 'light',
|
||||
|
||||
setTheme: (theme) => {
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolvedTheme);
|
||||
set({ theme, resolvedTheme });
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
const { theme } = get();
|
||||
const nextTheme: Theme =
|
||||
theme === 'light' ? 'dark' :
|
||||
theme === 'dark' ? 'system' : 'light';
|
||||
get().setTheme(nextTheme);
|
||||
},
|
||||
|
||||
initializeTheme: () => {
|
||||
const { theme } = get();
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolvedTheme);
|
||||
set({ resolvedTheme });
|
||||
|
||||
// Listen for system theme changes
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
const { theme } = get();
|
||||
if (theme === 'system') {
|
||||
const newResolvedTheme = getSystemTheme();
|
||||
applyTheme(newResolvedTheme);
|
||||
set({ resolvedTheme: newResolvedTheme });
|
||||
}
|
||||
};
|
||||
|
||||
// Modern browsers
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
mediaQuery.addListener(handleChange);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'theme-storage',
|
||||
partialize: (state) => ({ theme: state.theme }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { create } from "zustand";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (toast) => {
|
||||
const id = Math.random().toString(36).substring(2, 11);
|
||||
const newToast: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
duration: toast.duration ?? 5000, // Default 5 seconds
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, newToast],
|
||||
}));
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((toast) => toast.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearToasts: () => {
|
||||
set({ toasts: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper functions for common toast types
|
||||
export const toast = {
|
||||
success: (title: string, message?: string) => {
|
||||
useToastStore.getState().addToast({ type: "success", title, message });
|
||||
},
|
||||
error: (title: string, message?: string) => {
|
||||
useToastStore.getState().addToast({ type: "error", title, message, duration: 10000 });
|
||||
},
|
||||
info: (title: string, message?: string) => {
|
||||
useToastStore.getState().addToast({ type: "info", title, message });
|
||||
},
|
||||
warning: (title: string, message?: string) => {
|
||||
useToastStore.getState().addToast({ type: "warning", title, message });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export type ActiveView = "sidebar" | "list" | "viewer";
|
||||
|
||||
interface UIState {
|
||||
// Mobile view state
|
||||
activeView: ActiveView;
|
||||
sidebarOpen: boolean;
|
||||
|
||||
// Device detection (hydrated client-side)
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
|
||||
// Actions
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
|
||||
|
||||
// Navigation helpers
|
||||
showEmailList: () => void;
|
||||
showEmailViewer: () => void;
|
||||
goBack: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set, get) => ({
|
||||
// Initial state (SSR-safe defaults)
|
||||
activeView: "list",
|
||||
sidebarOpen: false,
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: true,
|
||||
|
||||
// Actions
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
setDeviceType: (isMobile, isTablet, isDesktop) =>
|
||||
set({ isMobile, isTablet, isDesktop }),
|
||||
|
||||
// Navigation helpers for mobile
|
||||
showEmailList: () => {
|
||||
const { isMobile } = get();
|
||||
if (isMobile) {
|
||||
set({ activeView: "list", sidebarOpen: false });
|
||||
}
|
||||
},
|
||||
|
||||
showEmailViewer: () => {
|
||||
const { isMobile } = get();
|
||||
if (isMobile) {
|
||||
set({ activeView: "viewer" });
|
||||
}
|
||||
},
|
||||
|
||||
goBack: () => {
|
||||
const { activeView, isMobile } = get();
|
||||
if (!isMobile) return;
|
||||
|
||||
if (activeView === "viewer") {
|
||||
set({ activeView: "list" });
|
||||
} else if (activeView === "list") {
|
||||
set({ sidebarOpen: true });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Inter",
|
||||
"system-ui",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fade-in 0.2s ease-out",
|
||||
"slide-in": "slide-in 0.3s ease-out",
|
||||
},
|
||||
keyframes: {
|
||||
"fade-in": {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
"slide-in": {
|
||||
"0%": { transform: "translateY(-10px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user