fix: adjust contact and calendar page for mobile layout #103

This commit is contained in:
Linus Rath
2026-03-26 16:30:20 +01:00
parent 05f848ee23
commit 9b25b6d03e
23 changed files with 272 additions and 180 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.8-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.4.9-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
+1 -1
View File
@@ -1 +1 @@
1.4.8
1.4.9
+33 -4
View File
@@ -90,6 +90,9 @@ export default function CalendarPage() {
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
const [showTaskModal, setShowTaskModal] = useState(false);
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
const [swipeKey, setSwipeKey] = useState(0);
const hasFetched = useRef(false);
// Sidebar resize state
@@ -230,6 +233,8 @@ export default function CalendarPage() {
const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
if (!touchStartRef.current || !isMobile) return;
// Week view has its own horizontal scroll, skip swipe navigation
if (normalizedViewMode === 'week') { touchStartRef.current = null; return; }
const touch = e.changedTouches[0];
const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y;
@@ -238,20 +243,34 @@ export default function CalendarPage() {
// Only trigger swipe if horizontal movement is dominant and fast enough
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
if (dx > 0) navigatePrev();
else navigateNext();
if (dx > 0) {
setSwipeDirection('right');
navigatePrev();
} else {
setSwipeDirection('left');
navigateNext();
}
}, [isMobile, navigatePrev, navigateNext]);
setSwipeKey(k => k + 1);
// Clear direction after animation completes
setTimeout(() => setSwipeDirection(null), 250);
}
}, [isMobile, normalizedViewMode, navigatePrev, navigateNext]);
const handleSelectDate = useCallback((date: Date) => {
setSelectedDate(date);
setMiniMonth(date);
// On mobile month view, tapping a date switches to day view
if (isMobile && normalizedViewMode === "month") {
setMobileReturnToMonth(true);
setViewMode("day");
}
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => {
setMobileReturnToMonth(false);
setViewMode("month");
}, [setViewMode]);
const handleMiniMonthChange = useCallback((date: Date) => {
setMiniMonth(date);
setSelectedDate(date);
@@ -887,11 +906,12 @@ export default function CalendarPage() {
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={setViewMode}
onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
@@ -903,8 +923,17 @@ export default function CalendarPage() {
data-tour="calendar-view"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div
key={swipeKey}
className={cn(
"flex flex-1 min-w-0",
isMobile && swipeDirection === 'left' && "animate-slide-in-right",
isMobile && swipeDirection === 'right' && "animate-slide-in-left",
)}
>
{renderView()}
</div>
{/* Desktop event panel */}
{!isMobile && showEventModal && (
+2 -2
View File
@@ -601,7 +601,7 @@ export default function ContactsPage() {
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)}
@@ -701,7 +701,7 @@ export default function ContactsPage() {
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
{t("back_to_contacts")}
</Button>
</div>
)}
+1 -1
View File
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.7";
const APP_VERSION = "1.4.9";
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
+31
View File
@@ -529,6 +529,37 @@ body {
animation: slide-in-from-left 0.3s ease-out;
}
/* Calendar swipe animations (subtler slide for view transitions) */
@keyframes cal-slide-in-right {
from {
opacity: 0;
transform: translateX(30%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes cal-slide-in-left {
from {
opacity: 0;
transform: translateX(-30%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: cal-slide-in-right 0.25s ease-out;
}
.animate-slide-in-left {
animation: cal-slide-in-left 0.25s ease-out;
}
/* Reduced motion: respect user OS preference */
@media (prefers-reduced-motion: reduce) {
*,
+66 -67
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ListTodo } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -40,6 +40,7 @@ export function CalendarToolbar({
onSubscribe,
isMobile,
firstDayOfWeek = 1,
onNavigateBack,
calendars,
selectedCalendarIds,
onToggleVisibility,
@@ -97,8 +98,7 @@ export function CalendarToolbar({
const [showImportDropdown, setShowImportDropdown] = useState(false);
const importDropdownRef = useRef<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showImportDropdown) return;
@@ -111,62 +111,69 @@ export function CalendarToolbar({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]);
useEffect(() => {
if (!showViewDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) {
setShowViewDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showViewDropdown]);
return (
<div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}>
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* ── MOBILE TOOLBAR ── */}
{isMobile && (
<div className="flex items-center gap-0.5">
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1">
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className={cn("text-sm font-medium text-center min-w-[80px]")}>
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
{getDateLabel()}
</span>
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
{t("views.today")}
</Button>
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
{getDateLabel()}
</span>
</div>
)}
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
{/* Row 2: View switcher pills + calendar toggle */}
<div className="flex items-center gap-1.5">
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
className={cn(
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
v === viewMode
? "bg-primary text-primary-foreground"
: "text-muted-foreground active:bg-muted"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
{calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<Button
variant="outline"
size="sm"
<button
onClick={() => setShowCalendarDropdown((v) => !v)}
className={cn(
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
)}
aria-label={t("my_calendars")}
className="touch-manipulation"
>
<CalendarDays className="w-4 h-4" />
</Button>
</button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
@@ -246,37 +253,29 @@ export function CalendarToolbar({
)}
</div>
)}
</div>
</div>
)}
{isMobile && (
<div className="relative" ref={viewDropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowViewDropdown((v) => !v)}
className="touch-manipulation capitalize text-xs"
>
{t(`views.${viewMode}`)}
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
{showViewDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]">
{views.map((v) => (
<button
key={v}
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
className={cn(
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
{getDateLabel()}
</span>
</div>
)}
<div className="flex-1" />
{!isMobile && (
+31 -14
View File
@@ -53,16 +53,13 @@ export function CalendarWeekView({
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekDays = useMemo(() => {
if (isMobile) {
// Show 3-day window centered on selected date
return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1));
}
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStart, isMobile]);
}, [selectedDate, weekStart]);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
@@ -137,6 +134,17 @@ export function CalendarWeekView({
const now = new Date();
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
}
// On mobile, scroll horizontally to center today's column
if (isMobile && rootRef.current) {
const todayIdx = weekDays.findIndex(d => isToday(d));
if (todayIdx >= 0) {
const gutter = 40;
const colWidth = (rootRef.current.scrollWidth - gutter) / 7;
const target = gutter + todayIdx * colWidth - rootRef.current.clientWidth / 2 + colWidth / 2;
rootRef.current.scrollLeft = Math.max(0, target);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [nowMinutes, setNowMinutes] = useState(() => {
@@ -175,20 +183,28 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const colCount = isMobile ? 3 : 7;
const colCount = 7;
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
<div
ref={rootRef}
className={cn(
"flex flex-col flex-1",
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)}
role="grid"
aria-label={t("views.week")}
>
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{t("events.all_day")}
</div>
<div
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
className="flex-1 relative grid gap-px bg-border grid-cols-7"
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{weekDays.map((day) => (
@@ -271,8 +287,8 @@ export function CalendarWeekView({
)}
<div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className="flex-1 border-l border-border grid grid-cols-7">
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
@@ -307,7 +323,7 @@ export function CalendarWeekView({
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => (
<div
key={h}
@@ -323,7 +339,7 @@ export function CalendarWeekView({
))}
</div>
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
<div className="flex-1 border-l border-border relative grid grid-cols-7">
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
@@ -480,5 +496,6 @@ export function CalendarWeekView({
</div>
</div>
</div>
</div>
);
}
+8 -8
View File
@@ -4635,13 +4635,13 @@ export function EmailViewer({
{/* Mobile bottom action bar */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden">
<div className="flex items-center justify-around">
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden overflow-hidden">
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
<button
onClick={onNavigatePrev}
disabled={!onNavigatePrev}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.previous')}
@@ -4652,7 +4652,7 @@ export function EmailViewer({
{isDraft && onEditDraft ? (
<button
onClick={onEditDraft}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-primary active:text-primary/80 transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')}
>
<EditIcon className="w-5 h-5" />
@@ -4662,7 +4662,7 @@ export function EmailViewer({
<>
<button
onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
@@ -4670,7 +4670,7 @@ export function EmailViewer({
</button>
<button
onClick={onReplyAll}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply_all')}
>
<ReplyAll className="w-5 h-5" />
@@ -4678,7 +4678,7 @@ export function EmailViewer({
</button>
<button
onClick={onForward}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.forward')}
>
<Forward className="w-5 h-5" />
@@ -4689,7 +4689,7 @@ export function EmailViewer({
onClick={onNavigateNext}
disabled={!onNavigateNext}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.next')}
+1 -1
View File
@@ -207,7 +207,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
)} />
)}
<span className="text-[10px] text-muted-foreground truncate">
{new URL(account.serverUrl).hostname}
{(() => { try { return new URL(account.serverUrl).hostname; } catch { return account.serverUrl; } })()}
</span>
</div>
</div>
+4 -4
View File
@@ -188,7 +188,7 @@ export function NavigationRail({
if (orientation === "horizontal") {
return (
<nav
className={cn("flex items-center justify-around bg-background border-t border-border shrink-0", className)}
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden", className)}
role="navigation"
aria-label={t("nav_label")}
>
@@ -201,7 +201,7 @@ export function NavigationRail({
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"transition-colors duration-150",
isActive
? "text-primary"
@@ -242,7 +242,7 @@ export function NavigationRail({
}
}}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"transition-colors duration-150",
isActive
? "text-primary"
@@ -265,7 +265,7 @@ export function NavigationRail({
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"transition-colors duration-150",
isSettingsActive
? "text-primary"
+7
View File
@@ -7,8 +7,15 @@
/** Generate a unique, deterministic account ID from username and server URL */
export function generateAccountId(username: string, serverUrl: string): string {
try {
const host = new URL(serverUrl).hostname;
return `${username}@${host}`;
} catch {
// Relative URL (e.g. /api/dev-jmap) use current origin as base
const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const host = new URL(serverUrl, base).hostname;
return `${username}@${host}`;
}
}
/** Deterministic avatar/accent color from an email string */
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Kontakt löschen",
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
"back_to_mail": "Zurück zur E-Mail",
"back_to_contacts": "Zurück zu Kontakten",
"tabs": {
"all": "Alle",
"groups": "Gruppen"
@@ -1803,6 +1803,7 @@
"calendar": {
"title": "Kalender",
"back_to_email": "Zurück zu E-Mails",
"back_to_month": "Zurück zur Monatsansicht",
"my_calendars": "Meine Kalender",
"mini_calendar_change": "Klicken, um den Monat zu wechseln",
"views": {
+2 -1
View File
@@ -1584,7 +1584,7 @@
"delete_confirm_title": "Delete contact",
"delete_confirm": "Are you sure you want to delete this contact?",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_mail": "Back to mail",
"back_to_contacts": "Back to contacts",
"tabs": {
"all": "All",
"groups": "Groups"
@@ -1811,6 +1811,7 @@
"calendar": {
"title": "Calendar",
"back_to_email": "Back to email",
"back_to_month": "Back to month",
"my_calendars": "Calendars",
"mini_calendar_change": "Click to change month",
"views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Eliminar contacto",
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
"back_to_mail": "Volver al correo",
"back_to_contacts": "Volver a contactos",
"tabs": {
"all": "Todos",
"groups": "Grupos"
@@ -1803,6 +1803,7 @@
"calendar": {
"title": "Calendario",
"back_to_email": "Volver al correo",
"back_to_month": "Volver al mes",
"my_calendars": "Mis calendarios",
"mini_calendar_change": "Clic para cambiar de mes",
"views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Supprimer le contact",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
"back_to_mail": "Retour aux e-mails",
"back_to_contacts": "Retour aux contacts",
"tabs": {
"all": "Tous",
"groups": "Groupes"
@@ -1803,6 +1803,7 @@
"calendar": {
"title": "Calendrier",
"back_to_email": "Retour aux e-mails",
"back_to_month": "Retour au mois",
"my_calendars": "Mes calendriers",
"mini_calendar_change": "Cliquer pour changer de mois",
"views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Elimina contatto",
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
"back_to_mail": "Torna alla posta",
"back_to_contacts": "Torna ai contatti",
"tabs": {
"all": "Tutti",
"groups": "Gruppi"
@@ -1803,6 +1803,7 @@
"calendar": {
"title": "Calendario",
"back_to_email": "Torna alla posta",
"back_to_month": "Torna al mese",
"my_calendars": "I miei calendari",
"mini_calendar_change": "Clicca per cambiare mese",
"views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "連絡先を削除",
"delete_confirm": "この連絡先を削除してもよろしいですか?",
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
"back_to_mail": "メールに戻る",
"back_to_contacts": "連絡先に戻る",
"tabs": {
"all": "すべて",
"groups": "グループ"
@@ -1803,6 +1803,7 @@
"calendar": {
"title": "カレンダー",
"back_to_email": "メールに戻る",
"back_to_month": "月表示に戻る",
"my_calendars": "マイカレンダー",
"mini_calendar_change": "クリックで月を変更",
"views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Contact verwijderen",
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
"back_to_mail": "Terug naar e-mail",
"back_to_contacts": "Terug naar contacten",
"tabs": {
"all": "Alle",
"groups": "Groepen"
@@ -1803,6 +1803,7 @@
"calendar": {
"title": "Agenda",
"back_to_email": "Terug naar e-mail",
"back_to_month": "Terug naar maand",
"my_calendars": "Mijn agenda's",
"mini_calendar_change": "Klik om van maand te wisselen",
"views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Excluir contato",
"delete_confirm": "Tem certeza de que deseja excluir este contato?",
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
"back_to_mail": "Voltar ao e-mail",
"back_to_contacts": "Voltar aos contatos",
"tabs": {
"all": "Todos",
"groups": "Grupos"
@@ -1803,6 +1803,7 @@
"calendar": {
"title": "Calendário",
"back_to_email": "Voltar ao e-mail",
"back_to_month": "Voltar ao mês",
"my_calendars": "Meus calendários",
"mini_calendar_change": "Clique para mudar o mês",
"views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Удалить контакт",
"delete_confirm": "Вы уверены, что хотите удалить этот контакт?",
"local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)",
"back_to_mail": "Вернуться к почте",
"back_to_contacts": "Вернуться к контактам",
"tabs": {
"all": "Все",
"groups": "Группы"
@@ -1803,6 +1803,7 @@
"calendar": {
"title": "Календарь",
"back_to_email": "Вернуться к почте",
"back_to_month": "Вернуться к месяцу",
"my_calendars": "Календари",
"mini_calendar_change": "Нажмите для смены месяца",
"views": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.4.8",
"version": "1.4.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.4.8",
"version": "1.4.9",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.4.8",
"version": "1.4.9",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",