diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 786da908..410ed688 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations } from 'next-intl'; -import { ArrowLeft, ChevronRight, Settings as SettingsIcon } from 'lucide-react'; +import { ArrowLeft, ChevronRight, LogOut, 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'; @@ -29,6 +29,7 @@ type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vac export default function SettingsPage() { const router = useRouter(); const t = useTranslations('settings'); + const tSidebar = useTranslations('sidebar'); const { client, isAuthenticated, logout } = useAuthStore(); const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); @@ -157,6 +158,17 @@ export default function SettingsPage() { ))} + + {/* Logout */} +
+ +
{/* Bottom Navigation */} diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index d7ce04a1..7aec12fa 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -1,6 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; +import { useCallback } from "react"; import { formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; @@ -10,6 +11,8 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailDrag } from "@/hooks/use-email-drag"; +import { useLongPress } from "@/hooks/use-long-press"; +import { useUIStore } from "@/stores/ui-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { getEmailColorTag } from "@/lib/thread-utils"; @@ -44,6 +47,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email sourceMailboxId: selectedMailbox, }); + const isMobile = useUIStore((state) => state.isMobile); + + const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( + useCallback((pos) => { + onContextMenu?.( + { preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent, + email + ); + }, [onContextMenu, email]), + isMobile + ); + const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }; + const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); toggleEmailSelection(email.id); @@ -56,8 +72,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email return (
{ if (e.ctrlKey || e.metaKey) { diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 0355eb6f..e9c9a782 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -1,13 +1,16 @@ "use client"; +import { useCallback } from "react"; import { formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react"; import { useEmailDrag } from "@/hooks/use-email-drag"; +import { useLongPress } from "@/hooks/use-long-press"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useUIStore } from "@/stores/ui-store"; interface ThreadEmailItemProps { email: Email; @@ -36,6 +39,19 @@ export function ThreadEmailItem({ sourceMailboxId: selectedMailbox, }); + const isMobile = useUIStore((state) => state.isMobile); + + const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( + useCallback((pos) => { + onContextMenu?.( + { preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent, + email + ); + }, [onContextMenu, email]), + isMobile + ); + const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }; + const handleContextMenu = (e: React.MouseEvent) => { onContextMenu?.(e, email); }; @@ -61,8 +77,9 @@ export function ThreadEmailItem({ return (
( sourceMailboxId: selectedMailbox, }); + const isMobile = useUIStore((state) => state.isMobile); + + const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( + useCallback((pos) => { + onContextMenu?.( + { preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent, + email + ); + }, [onContextMenu, email]), + isMobile + ); + const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }; + const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); toggleEmailSelection(email.id); @@ -84,8 +98,9 @@ const SingleEmailItem = React.forwardRef(
( resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110", isUnread && !resolvedColorTag && "bg-accent/30", isChecked && "ring-2 ring-primary/20 bg-accent/40", - isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30" + isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", + isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" )} onClick={handleClick} onContextMenu={handleContextMenu} @@ -231,6 +247,17 @@ export const ThreadListItem = React.forwardRef { + onContextMenu?.( + { preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent, + latestEmail + ); + }, [onContextMenu, latestEmail]), + isMobile + ); + const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel }; + const threadColor = getThreadColorTag(thread.emails); const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords); const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null; @@ -310,8 +337,9 @@ export const ThreadListItem = React.forwardRef
state.isMobile); const handleDragStart = useCallback((e: DragEvent) => { // Determine which emails to drag: @@ -88,11 +90,13 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const isThisEmailDragging = isDragging && draggedEmails.some(em => em.id === email.id); return { - dragHandlers: { - draggable: true, - onDragStart: handleDragStart, - onDragEnd: handleDragEnd, - }, + dragHandlers: isMobile + ? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} } + : { + draggable: true, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, + }, isDragging: isThisEmailDragging, }; } diff --git a/hooks/use-long-press.ts b/hooks/use-long-press.ts new file mode 100644 index 00000000..972c5fd2 --- /dev/null +++ b/hooks/use-long-press.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +const LONG_PRESS_DURATION = 500; +const MOVE_THRESHOLD = 10; + +export function useLongPress( + onLongPress: (position: { clientX: number; clientY: number }) => void, + enabled: boolean = true +) { + const timerRef = useRef | null>(null); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + const firedRef = useRef(false); + const [isPressed, setIsPressed] = useState(false); + + const cancel = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + startPosRef.current = null; + setIsPressed(false); + }, []); + + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + if (!enabled) return; + const touch = e.touches[0]; + startPosRef.current = { x: touch.clientX, y: touch.clientY }; + firedRef.current = false; + setIsPressed(true); + + timerRef.current = setTimeout(() => { + firedRef.current = true; + setIsPressed(false); + onLongPress({ clientX: touch.clientX, clientY: touch.clientY }); + }, LONG_PRESS_DURATION); + }, + [enabled, onLongPress] + ); + + const onTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!startPosRef.current || !timerRef.current) return; + const touch = e.touches[0]; + const dx = Math.abs(touch.clientX - startPosRef.current.x); + const dy = Math.abs(touch.clientY - startPosRef.current.y); + if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) { + cancel(); + } + }, + [cancel] + ); + + const onTouchEnd = useCallback( + (e: React.TouchEvent) => { + if (firedRef.current) { + e.preventDefault(); + } + cancel(); + firedRef.current = false; + }, + [cancel] + ); + + const onTouchCancel = useCallback(() => { + cancel(); + firedRef.current = false; + }, [cancel]); + + return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed }; +} diff --git a/next.config.ts b/next.config.ts index ec6b506a..f0b267cd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,7 @@ import createNextIntlPlugin from "next-intl/plugin"; const nextConfig: NextConfig = { output: "standalone", + allowedDevOrigins: ["192.168.1.51"], turbopack: { root: import.meta.dirname, },