feat: add demo data for emails, files, filters, identities, mailboxes, vacation responses, and JMAP client interface
- Created demo emails with various states (inbox, sent, drafts, trash, etc.) in `emails.ts`. - Added demo file nodes representing directories and files in `files.ts`. - Implemented demo Sieve capabilities and scripts in `filters.ts`. - Defined demo identities for users in `identities.ts`. - Established demo mailboxes with permissions and counts in `mailboxes.ts`. - Created a demo vacation response in `vacation.ts`. - Introduced a comprehensive JMAP client interface in `client-interface.ts` to standardize interactions with the JMAP API.
This commit is contained in:
@@ -795,6 +795,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex flex-1 overflow-hidden relative"
|
className="flex flex-1 overflow-hidden relative"
|
||||||
|
data-tour="calendar-view"
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -606,6 +606,7 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
{/* Panel 2: Contact list */}
|
{/* Panel 2: Contact list */}
|
||||||
<div
|
<div
|
||||||
|
data-tour="contacts-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||||
isMobile ? "w-full" : "",
|
isMobile ? "w-full" : "",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||||
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -26,7 +27,9 @@ export default async function LocaleLayout({
|
|||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CalendarAlertProvider>
|
<CalendarAlertProvider>
|
||||||
{children}
|
<TourProvider>
|
||||||
|
{children}
|
||||||
|
</TourProvider>
|
||||||
</CalendarAlertProvider>
|
</CalendarAlertProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
+191
-4
@@ -11,7 +11,7 @@ import { useThemeStore } from "@/stores/theme-store";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
|
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
@@ -30,9 +30,9 @@ export default function LoginPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -54,6 +54,7 @@ export default function LoginPage() {
|
|||||||
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
||||||
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
||||||
const [oauthLoading, setOauthLoading] = useState(false);
|
const [oauthLoading, setOauthLoading] = useState(false);
|
||||||
|
const [demoLoading, setDemoLoading] = useState(false);
|
||||||
|
|
||||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -206,7 +207,7 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverUrl) {
|
if (!serverUrl && !demoMode) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||||
@@ -353,9 +354,167 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDemoLogin = async () => {
|
||||||
|
setDemoLoading(true);
|
||||||
|
const success = await loginDemo();
|
||||||
|
if (success) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
setDemoLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
||||||
const CurrentThemeIcon = currentThemeOption.icon;
|
const CurrentThemeIcon = currentThemeOption.icon;
|
||||||
|
|
||||||
|
// Demo-only mode: show only a large demo login button
|
||||||
|
if (demoMode && !isAddAccountMode) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
|
||||||
|
showThemeMenu
|
||||||
|
? "bg-secondary border-border text-foreground shadow-md"
|
||||||
|
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
|
||||||
|
)}
|
||||||
|
aria-label={`Theme: ${currentThemeOption.label}`}
|
||||||
|
aria-expanded={showThemeMenu}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
<CurrentThemeIcon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showThemeMenu && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Theme selection"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isActive = theme === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => handleThemeSelect(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="flex-1 text-left">{option.label}</span>
|
||||||
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-[440px] mx-auto">
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||||
|
{/* Header with logo */}
|
||||||
|
<div className="px-8 pt-12 pb-4 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||||
|
<img
|
||||||
|
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||||
|
alt={appName}
|
||||||
|
className="max-w-20 max-h-20 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground tracking-tight">
|
||||||
|
{appName}
|
||||||
|
</h1>
|
||||||
|
<p className="text-base text-muted-foreground mt-2 max-w-xs mx-auto leading-relaxed">
|
||||||
|
{t("demo_tagline")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Large demo button */}
|
||||||
|
<div className="px-8 pb-10 pt-4">
|
||||||
|
{error && (
|
||||||
|
<div className={cn(
|
||||||
|
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||||
|
shakeError && "animate-shake"
|
||||||
|
)}>
|
||||||
|
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
||||||
|
{t(`error.${error}`) || t("error.generic")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-14 font-semibold text-lg bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={demoLoading || isLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
{t("demo_launching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
{t("demo_login_button")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-4 leading-relaxed">
|
||||||
|
{t("demo_no_signup")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-2">
|
||||||
|
{loginCompanyName && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground/60 font-medium">
|
||||||
|
{loginCompanyName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||||
|
{loginWebsiteUrl && (
|
||||||
|
<a href={loginWebsiteUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("website")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{loginImprintUrl && (
|
||||||
|
<a href={loginImprintUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("imprint")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{loginPrivacyPolicyUrl && (
|
||||||
|
<a href={loginPrivacyPolicyUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("privacy_policy")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-center text-xs text-muted-foreground/40">
|
||||||
|
v{APP_VERSION}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||||
{/* Theme toggle - top right, dropdown style */}
|
{/* Theme toggle - top right, dropdown style */}
|
||||||
@@ -747,6 +906,34 @@ export default function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Demo Mode Button */}
|
||||||
|
{demoMode && !isAddAccountMode && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border/40">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={demoLoading || isLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{t("demo_launching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
{t("try_demo")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-2">
|
||||||
|
{t("demo_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1174,6 +1174,7 @@ export default function Home() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
|
data-tour="search-input"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -1579,8 +1580,8 @@ export default function Home() {
|
|||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
currentUserEmail={client?.["username"]}
|
currentUserEmail={client?.getUsername()}
|
||||||
currentUserName={client?.["username"]?.split("@")[0]}
|
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
<div className="px-2 space-y-0.5">
|
<div className="px-2 space-y-0.5">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ export async function GET() {
|
|||||||
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
|
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
|
||||||
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
||||||
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
||||||
|
demoMode: process.env.DEMO_MODE === 'true',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { CalendarColorPicker } from "@/components/settings/calendar-management-s
|
|||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -17,7 +17,7 @@ interface CalendarSidebarPanelProps {
|
|||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
client?: JMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarSidebarPanel({
|
export function CalendarSidebarPanel({
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export function CalendarToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" onClick={onCreateEvent}>
|
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ export function EventModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isEdit ? t("events.edit") : t("events.create")}
|
{isEdit ? t("events.edit") : t("events.create")}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalImportModalProps {
|
interface ICalImportModalProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
client: JMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2, Globe } from "lucide-react";
|
import { X, Loader2, Globe } from "lucide-react";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalSubscriptionModalProps {
|
interface ICalSubscriptionModalProps {
|
||||||
client: JMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -886,6 +886,7 @@ export function EmailComposer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
className={cn("flex flex-col h-full bg-background relative", className)}
|
||||||
|
data-tour="composer"
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export function EmailList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email List */}
|
{/* Email List */}
|
||||||
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
|
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
|
||||||
{/* Loading overlay */}
|
{/* Loading overlay */}
|
||||||
{isLoading && emails.length > 0 && (
|
{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="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
PlayCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||||
@@ -80,6 +81,7 @@ import { useThemeStore } from "@/stores/theme-store";
|
|||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||||
import { RecipientPopover } from "./recipient-popover";
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
@@ -871,6 +873,8 @@ export function EmailViewer({
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tSmime = useTranslations('smime');
|
const tSmime = useTranslations('smime');
|
||||||
const tFiles = useTranslations('files');
|
const tFiles = useTranslations('files');
|
||||||
|
const tDemoWelcome = useTranslations('demo_welcome');
|
||||||
|
const tWelcome = useTranslations('welcome');
|
||||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||||
@@ -898,8 +902,9 @@ export function EmailViewer({
|
|||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client } = useAuthStore();
|
const { identities, client, isDemoMode } = useAuthStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const { startTour } = useTour();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||||
@@ -2684,6 +2689,52 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
if (isDemoMode) {
|
||||||
|
const logoSrc = resolvedTheme === 'dark'
|
||||||
|
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||||
|
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
|
||||||
|
return (
|
||||||
|
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||||
|
<div className="text-center p-8 max-w-md">
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt="Bulwark Mail"
|
||||||
|
className="h-12 mx-auto mb-6"
|
||||||
|
/>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">{tDemoWelcome('description')}</p>
|
||||||
|
<div className="flex flex-col gap-3 items-center">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-left text-sm text-muted-foreground w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{tDemoWelcome('feature_email')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{tDemoWelcome('feature_organize')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Keyboard className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{tDemoWelcome('feature_shortcuts')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{tDemoWelcome('feature_privacy')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={startTour}
|
||||||
|
className="mt-4 inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
{tWelcome('start_tour')}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted-foreground/60 mt-2">{tDemoWelcome('hint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||||
<div className="text-center p-8">
|
<div className="text-center p-8">
|
||||||
@@ -3233,6 +3284,7 @@ export function EmailViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={email.id}
|
key={email.id}
|
||||||
|
data-tour="email-viewer"
|
||||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
||||||
>
|
>
|
||||||
{/* Mobile More menu sidebar overlay */}
|
{/* Mobile More menu sidebar overlay */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { X, Keyboard } from "lucide-react";
|
|||||||
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
|
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
|
||||||
interface KeyboardShortcutsModalProps {
|
interface KeyboardShortcutsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,6 +14,7 @@ interface KeyboardShortcutsModalProps {
|
|||||||
|
|
||||||
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
|
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const { startTour } = useTour();
|
||||||
|
|
||||||
const modalRef = useFocusTrap({
|
const modalRef = useFocusTrap({
|
||||||
isActive: isOpen,
|
isActive: isOpen,
|
||||||
@@ -144,6 +146,14 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
|||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
{t("shortcuts.tip")}
|
{t("shortcuts.tip")}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm text-center mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); startTour(); }}
|
||||||
|
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
{t("tour.take_a_tour")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ export function NavigationRail({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
|
data-tour={`nav-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||||
collapsed
|
collapsed
|
||||||
@@ -401,6 +402,7 @@ export function NavigationRail({
|
|||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
|
data-tour="nav-settings"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
||||||
isSettingsActive
|
isSettingsActive
|
||||||
@@ -418,6 +420,7 @@ export function NavigationRail({
|
|||||||
{onShowShortcuts && (
|
{onShowShortcuts && (
|
||||||
<button
|
<button
|
||||||
onClick={onShowShortcuts}
|
onClick={onShowShortcuts}
|
||||||
|
data-tour="nav-shortcuts"
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
title={t("keyboard_shortcuts")}
|
title={t("keyboard_shortcuts")}
|
||||||
>
|
>
|
||||||
@@ -426,7 +429,9 @@ export function NavigationRail({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{quota && quota.total > 0 && (
|
{quota && quota.total > 0 && (
|
||||||
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
<div data-tour="storage-quota">
|
||||||
|
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPushConnected != null && (
|
{isPushConnected != null && (
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
Tag,
|
Tag,
|
||||||
|
RotateCcw,
|
||||||
|
FlaskConical,
|
||||||
|
PlayCircle,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
@@ -40,6 +44,7 @@ import { debug } from "@/lib/debug";
|
|||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { AccountSwitcher } from "./account-switcher";
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
mailboxes: Mailbox[];
|
mailboxes: Mailbox[];
|
||||||
@@ -328,6 +333,68 @@ function TagItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DemoBanner() {
|
||||||
|
const t = useTranslations('sidebar');
|
||||||
|
const { isDemoMode, loginDemo } = useAuthStore();
|
||||||
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
if (!isDemoMode) return null;
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
setIsResetting(true);
|
||||||
|
// Navigate to home first so the mail page re-fetches data
|
||||||
|
router.push('/');
|
||||||
|
await loginDemo();
|
||||||
|
setIsResetting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartTour = () => {
|
||||||
|
resetTourCompletion();
|
||||||
|
router.push('/');
|
||||||
|
setTimeout(() => startTour(), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tour="demo-banner"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1.5 w-full px-3 py-2 text-xs",
|
||||||
|
"bg-primary/10 dark:bg-primary/10 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="truncate font-medium">{t("demo_banner")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={handleStartTour}
|
||||||
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||||
|
title={t("demo_tour")}
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-3 h-3" />
|
||||||
|
{t("demo_tour")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isResetting}
|
||||||
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||||
|
title={t("demo_reset")}
|
||||||
|
>
|
||||||
|
{isResetting ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
{t("demo_reset")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VacationBanner() {
|
function VacationBanner() {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -491,11 +558,14 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Demo Banner */}
|
||||||
|
{!isCollapsed && <DemoBanner />}
|
||||||
|
|
||||||
{/* Vacation Banner */}
|
{/* Vacation Banner */}
|
||||||
{!isCollapsed && <VacationBanner />}
|
{!isCollapsed && <VacationBanner />}
|
||||||
|
|
||||||
{/* Mailbox List */}
|
{/* Mailbox List */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{mailboxes.length === 0 ? (
|
{mailboxes.length === 0 ? (
|
||||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
@@ -582,7 +652,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<div className="relative">
|
<div className="relative" data-tour="keyword-tags">
|
||||||
{emailKeywords.map((kw) => {
|
{emailKeywords.map((kw) => {
|
||||||
const isSelected = selectedKeyword === kw.id;
|
const isSelected = selectedKeyword === kw.id;
|
||||||
return (
|
return (
|
||||||
@@ -606,11 +676,11 @@ export function Sidebar({
|
|||||||
{/* Compose Button */}
|
{/* Compose Button */}
|
||||||
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")}>
|
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
|
||||||
<PenSquare className="w-5 h-5" />
|
<PenSquare className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={onCompose} className="w-full" title={t("compose_hint")}>
|
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
|
||||||
<PenSquare className="w-4 h-4 mr-2" />
|
<PenSquare className="w-4 h-4 mr-2" />
|
||||||
{t("compose")}
|
{t("compose")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ import { formatFileSize } from '@/lib/utils';
|
|||||||
|
|
||||||
export function AccountSettings() {
|
export function AccountSettings() {
|
||||||
const t = useTranslations('settings.account');
|
const t = useTranslations('settings.account');
|
||||||
const { username, serverUrl } = useAuthStore();
|
const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore();
|
||||||
const { quota } = useEmailStore();
|
const { quota } = useEmailStore();
|
||||||
|
|
||||||
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
|
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
|
||||||
|
const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
|
{/* Display Name (show in demo mode or when identity has a name) */}
|
||||||
|
{displayName && (
|
||||||
|
<SettingItem label={t('name_label')}>
|
||||||
|
<span className="text-sm text-foreground">{displayName}</span>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Email Address */}
|
{/* Email Address */}
|
||||||
<SettingItem label={t('email.label')}>
|
<SettingItem label={t('email.label')}>
|
||||||
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
|
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
|
||||||
@@ -49,6 +57,16 @@ export function AccountSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Demo mode indicator */}
|
||||||
|
{isDemoMode && (
|
||||||
|
<SettingItem label={t('account_type_label')}>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||||
|
{t('demo_account')}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/s
|
|||||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTour } from '@/components/tour/tour-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PlayCircle } from 'lucide-react';
|
||||||
|
|
||||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||||
@@ -61,8 +64,10 @@ function DensityPreview({ density }: { density: Density }) {
|
|||||||
|
|
||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
const t = useTranslations('settings.appearance');
|
const t = useTranslations('settings.appearance');
|
||||||
|
const tTour = useTranslations('tour');
|
||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
|
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
|
||||||
|
const { startTour, resetTourCompletion } = useTour();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
@@ -141,6 +146,19 @@ export function AppearanceSettings() {
|
|||||||
onChange={(checked) => updateSetting('animationsEnabled', checked)}
|
onChange={(checked) => updateSetting('animationsEnabled', checked)}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Restart Tour */}
|
||||||
|
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { resetTourCompletion(); startTour(); }}
|
||||||
|
className="text-xs h-7"
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{tTour('restart_button')}
|
||||||
|
</Button>
|
||||||
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTour } from "./tour-provider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PADDING = 8;
|
||||||
|
const TOOLTIP_GAP = 12;
|
||||||
|
const TOOLTIP_MAX_W = 360;
|
||||||
|
|
||||||
|
function getTargetRect(selector: string): Rect | null {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
// Element might exist but be hidden (zero dimensions)
|
||||||
|
if (r.width === 0 && r.height === 0) return null;
|
||||||
|
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTooltipPosition(
|
||||||
|
target: Rect,
|
||||||
|
placement: "top" | "bottom" | "left" | "right",
|
||||||
|
tooltipSize: { width: number; height: number }
|
||||||
|
): { top: number; left: number; actualPlacement: string } {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const tw = Math.max(tooltipSize.width, 200); // minimum fallback width
|
||||||
|
const th = Math.max(tooltipSize.height, 100); // minimum fallback height
|
||||||
|
|
||||||
|
const positions = {
|
||||||
|
bottom: {
|
||||||
|
top: target.top + target.height + PADDING + TOOLTIP_GAP,
|
||||||
|
left: target.left + target.width / 2 - tw / 2,
|
||||||
|
},
|
||||||
|
top: {
|
||||||
|
top: target.top - PADDING - TOOLTIP_GAP - th,
|
||||||
|
left: target.left + target.width / 2 - tw / 2,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
top: target.top + target.height / 2 - th / 2,
|
||||||
|
left: target.left + target.width + PADDING + TOOLTIP_GAP,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
top: target.top + target.height / 2 - th / 2,
|
||||||
|
left: target.left - PADDING - TOOLTIP_GAP - tw,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fits = (p: { top: number; left: number }) =>
|
||||||
|
p.top >= 8 && p.left >= 8 && p.top + th <= vh - 8 && p.left + tw <= vw - 8;
|
||||||
|
|
||||||
|
// Try preferred placement first, then fallback order
|
||||||
|
const order: Array<"top" | "bottom" | "left" | "right"> = [placement, "bottom", "right", "left", "top"];
|
||||||
|
for (const dir of order) {
|
||||||
|
const pos = positions[dir];
|
||||||
|
if (fits(pos)) return { ...pos, actualPlacement: dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing fits perfectly, use preferred but clamped
|
||||||
|
const pos = positions[placement];
|
||||||
|
return {
|
||||||
|
top: Math.max(8, Math.min(pos.top, vh - th - 8)),
|
||||||
|
left: Math.max(8, Math.min(pos.left, vw - tw - 8)),
|
||||||
|
actualPlacement: placement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TourOverlay() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour();
|
||||||
|
const step = steps[currentStep];
|
||||||
|
|
||||||
|
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||||
|
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Use refs for callbacks to avoid stale closures in timers/intervals
|
||||||
|
const updatePositionRef = useRef<() => void>(() => {});
|
||||||
|
const nextStepRef = useRef<() => void>(() => {});
|
||||||
|
nextStepRef.current = nextStep;
|
||||||
|
|
||||||
|
const focusTrapRef = useFocusTrap({
|
||||||
|
isActive: visible,
|
||||||
|
onEscape: stopTour,
|
||||||
|
restoreFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set mounted for portal
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (!step) return;
|
||||||
|
const rect = getTargetRect(step.target);
|
||||||
|
|
||||||
|
if (rect) {
|
||||||
|
setTargetRect(rect);
|
||||||
|
if (tooltipRef.current) {
|
||||||
|
const { width, height } = tooltipRef.current.getBoundingClientRect();
|
||||||
|
const pos = computeTooltipPosition(rect, step.placement, { width, height });
|
||||||
|
setTooltipPos({ top: pos.top, left: pos.left });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If rect is null, keep previous targetRect (element temporarily hidden during scroll/resize)
|
||||||
|
// Only the step-change effect should null out targetRect
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
// Keep ref in sync
|
||||||
|
updatePositionRef.current = updatePosition;
|
||||||
|
|
||||||
|
// Wait for target element to appear, then show
|
||||||
|
useEffect(() => {
|
||||||
|
if (!step) return;
|
||||||
|
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" — target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
|
||||||
|
setVisible(false);
|
||||||
|
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
|
||||||
|
// instead of disappearing and reappearing
|
||||||
|
|
||||||
|
// Clear any pending timer from a previous step
|
||||||
|
if (pendingTimerRef.current) {
|
||||||
|
clearTimeout(pendingTimerRef.current);
|
||||||
|
pendingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run beforeAction if defined (e.g. click an email to open the viewer)
|
||||||
|
if (step.beforeAction) {
|
||||||
|
step.beforeAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 50; // 5 seconds
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const tryFind = () => {
|
||||||
|
if (cancelled) return true;
|
||||||
|
const el = document.querySelector(step.target);
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`);
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
// Delay after scroll for layout to settle
|
||||||
|
pendingTimerRef.current = setTimeout(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`);
|
||||||
|
updatePositionRef.current();
|
||||||
|
setVisible(true);
|
||||||
|
// Second position update after tooltip renders with final dimensions
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!cancelled) updatePositionRef.current();
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (attempts % 10 === 0) {
|
||||||
|
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tryFind()) return () => { cancelled = true; };
|
||||||
|
|
||||||
|
// Poll for element appearance (for page navigation)
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
attempts++;
|
||||||
|
if (tryFind() || attempts >= maxAttempts) {
|
||||||
|
clearInterval(interval);
|
||||||
|
if (attempts >= maxAttempts && !cancelled) {
|
||||||
|
// Skip this step if element never appears
|
||||||
|
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED — element never appeared after ${maxAttempts} attempts`);
|
||||||
|
nextStepRef.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
if (pendingTimerRef.current) {
|
||||||
|
clearTimeout(pendingTimerRef.current);
|
||||||
|
pendingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Recalculate on resize/scroll (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
const handler = () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handler);
|
||||||
|
window.addEventListener("scroll", handler, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handler);
|
||||||
|
window.removeEventListener("scroll", handler, true);
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [visible, updatePosition]);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowRight" || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
nextStep();
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
prevStep();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
stopTour();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler, true);
|
||||||
|
return () => window.removeEventListener("keydown", handler, true);
|
||||||
|
}, [nextStep, prevStep, stopTour]);
|
||||||
|
|
||||||
|
// Re-position after tooltip content renders with new dimensions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !tooltipRef.current) return;
|
||||||
|
// Use rAF to wait for the browser to lay out the tooltip content
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [visible, updatePosition, currentStep]);
|
||||||
|
|
||||||
|
if (!mounted || !step) return null;
|
||||||
|
|
||||||
|
const cutout = targetRect
|
||||||
|
? {
|
||||||
|
x: targetRect.left - PADDING,
|
||||||
|
y: targetRect.top - PADDING,
|
||||||
|
w: targetRect.width + PADDING * 2,
|
||||||
|
h: targetRect.height + PADDING * 2,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isLast = currentStep >= totalSteps - 1;
|
||||||
|
const isFirst = currentStep === 0;
|
||||||
|
const isInteractive = step.interactive;
|
||||||
|
|
||||||
|
const reducedMotion =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
const transitionStyle = reducedMotion ? "none" : "all 300ms ease";
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* SVG overlay with cutout */}
|
||||||
|
<svg
|
||||||
|
className="fixed inset-0 z-[9998]"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ pointerEvents: isInteractive ? "none" : "auto" }}
|
||||||
|
onClick={stopTour}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<mask id="tour-mask">
|
||||||
|
<rect fill="white" width="100%" height="100%" />
|
||||||
|
{cutout && (
|
||||||
|
<rect
|
||||||
|
fill="black"
|
||||||
|
x={cutout.x}
|
||||||
|
y={cutout.y}
|
||||||
|
width={cutout.w}
|
||||||
|
height={cutout.h}
|
||||||
|
rx="8"
|
||||||
|
style={{ transition: transitionStyle }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
fill="black"
|
||||||
|
opacity="0.5"
|
||||||
|
mask="url(#tour-mask)"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Click-through cutout zone for interactive steps */}
|
||||||
|
{isInteractive && cutout && (
|
||||||
|
<div
|
||||||
|
className="fixed z-[9998]"
|
||||||
|
style={{
|
||||||
|
top: cutout.y,
|
||||||
|
left: cutout.x,
|
||||||
|
width: cutout.w,
|
||||||
|
height: cutout.h,
|
||||||
|
pointerEvents: "none",
|
||||||
|
transition: transitionStyle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-interactive overlay click blocker around cutout */}
|
||||||
|
{!isInteractive && cutout && (
|
||||||
|
<div
|
||||||
|
className="fixed z-[9998]"
|
||||||
|
style={{
|
||||||
|
top: cutout.y,
|
||||||
|
left: cutout.x,
|
||||||
|
width: cutout.w,
|
||||||
|
height: cutout.h,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
(focusTrapRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t(step.titleKey)}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-[9999] transition-all",
|
||||||
|
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: tooltipPos?.top ?? -9999,
|
||||||
|
left: tooltipPos?.left ?? -9999,
|
||||||
|
maxWidth: TOOLTIP_MAX_W,
|
||||||
|
transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="bg-background border border-border rounded-xl shadow-2xl p-4">
|
||||||
|
{/* Step counter */}
|
||||||
|
<p className="text-xs text-muted-foreground mb-1" aria-live="polite">
|
||||||
|
{t("tour.step_counter", { current: currentStep + 1, total: totalSteps })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-semibold text-sm text-foreground">{t(step.titleKey)}</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{t(step.descriptionKey)}</p>
|
||||||
|
|
||||||
|
{/* Navigation buttons */}
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<button
|
||||||
|
onClick={stopTour}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
{t("tour.skip")}
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={prevStep}
|
||||||
|
disabled={isFirst}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-3 py-1.5 rounded-md border border-border transition-colors",
|
||||||
|
isFirst
|
||||||
|
? "opacity-40 cursor-not-allowed"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("tour.back")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={nextStep}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
{isLast ? t("tour.finish") : t("tour.next")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="flex justify-center gap-1 mt-2">
|
||||||
|
{steps.map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full transition-colors",
|
||||||
|
i === currentStep ? "bg-primary" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||||
|
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||||
|
import { TourOverlay } from "./tour-overlay";
|
||||||
|
|
||||||
|
const TOUR_COMPLETED_KEY = "tour_completed";
|
||||||
|
const TOUR_CURRENT_STEP_KEY = "tour_current_step";
|
||||||
|
|
||||||
|
interface TourContextValue {
|
||||||
|
isActive: boolean;
|
||||||
|
currentStep: number;
|
||||||
|
totalSteps: number;
|
||||||
|
steps: TourStep[];
|
||||||
|
startTour: () => void;
|
||||||
|
stopTour: () => void;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
hasCompletedTour: boolean;
|
||||||
|
resetTourCompletion: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TourContext = createContext<TourContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useTour() {
|
||||||
|
const ctx = useContext(TourContext);
|
||||||
|
if (!ctx) throw new Error("useTour must be used within TourProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TourProvider({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isDemoMode } = useAuthStore();
|
||||||
|
const { supportsCalendar } = useCalendarStore();
|
||||||
|
const { supportsWebDAV } = useWebDAVStore();
|
||||||
|
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [hasCompletedTour, setHasCompletedTour] = useState(false);
|
||||||
|
|
||||||
|
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTour = useCallback(() => {
|
||||||
|
let resumeStep = 0;
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = parseInt(stored, 10);
|
||||||
|
if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed;
|
||||||
|
}
|
||||||
|
} catch { /* */ }
|
||||||
|
|
||||||
|
// If the resume step is beyond the current steps, start from 0
|
||||||
|
if (resumeStep >= steps.length) resumeStep = 0;
|
||||||
|
|
||||||
|
setCurrentStep(resumeStep);
|
||||||
|
setIsActive(true);
|
||||||
|
}, [steps.length]);
|
||||||
|
|
||||||
|
const stopTour = useCallback(() => {
|
||||||
|
setIsActive(false);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const completeTour = useCallback(() => {
|
||||||
|
setIsActive(false);
|
||||||
|
setHasCompletedTour(true);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
|
||||||
|
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextStep = useCallback(() => {
|
||||||
|
if (currentStep >= steps.length - 1) {
|
||||||
|
completeTour();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = currentStep + 1;
|
||||||
|
const nextStepDef = steps[next];
|
||||||
|
setCurrentStep(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
|
||||||
|
} catch { /* */ }
|
||||||
|
|
||||||
|
// Navigate if the next step requires a different page
|
||||||
|
if (nextStepDef?.page) {
|
||||||
|
router.push(nextStepDef.page);
|
||||||
|
}
|
||||||
|
}, [currentStep, steps, completeTour, router]);
|
||||||
|
|
||||||
|
const prevStep = useCallback(() => {
|
||||||
|
if (currentStep <= 0) return;
|
||||||
|
const prev = currentStep - 1;
|
||||||
|
const prevStepDef = steps[prev];
|
||||||
|
setCurrentStep(prev);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev));
|
||||||
|
} catch { /* */ }
|
||||||
|
|
||||||
|
if (prevStepDef?.page) {
|
||||||
|
router.push(prevStepDef.page);
|
||||||
|
} else if (steps[currentStep]?.page) {
|
||||||
|
// Going back from a page-specific step to a non-page step => go to mail
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
}, [currentStep, steps, router]);
|
||||||
|
|
||||||
|
const resetTourCompletion = useCallback(() => {
|
||||||
|
setHasCompletedTour(false);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TOUR_COMPLETED_KEY);
|
||||||
|
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||||
|
} catch { /* */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: TourContextValue = {
|
||||||
|
isActive,
|
||||||
|
currentStep,
|
||||||
|
totalSteps: steps.length,
|
||||||
|
steps,
|
||||||
|
startTour,
|
||||||
|
stopTour,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
hasCompletedTour,
|
||||||
|
resetTourCompletion,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{isActive && <TourOverlay />}
|
||||||
|
</TourContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
export interface TourStep {
|
||||||
|
id: string;
|
||||||
|
target: string;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
placement: "top" | "bottom" | "left" | "right";
|
||||||
|
interactive?: boolean;
|
||||||
|
spotlight?: "rect" | "circle";
|
||||||
|
page?: string;
|
||||||
|
demoOnly?: boolean;
|
||||||
|
beforeAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BASE_TOUR_STEPS: TourStep[] = [
|
||||||
|
{
|
||||||
|
id: "sidebar",
|
||||||
|
target: '[data-tour="sidebar"]',
|
||||||
|
titleKey: "tour.sidebar_title",
|
||||||
|
descriptionKey: "tour.sidebar_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compose",
|
||||||
|
target: '[data-tour="compose-button"]',
|
||||||
|
titleKey: "tour.compose_title",
|
||||||
|
descriptionKey: "tour.compose_desc",
|
||||||
|
placement: "right",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "search",
|
||||||
|
target: '[data-tour="search-input"]',
|
||||||
|
titleKey: "tour.search_title",
|
||||||
|
descriptionKey: "tour.search_desc",
|
||||||
|
placement: "bottom",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email-list",
|
||||||
|
target: '[data-tour="email-list"]',
|
||||||
|
titleKey: "tour.email_list_title",
|
||||||
|
descriptionKey: "tour.email_list_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email-viewer",
|
||||||
|
target: '[data-tour="email-viewer"]',
|
||||||
|
titleKey: "tour.email_viewer_title",
|
||||||
|
descriptionKey: "tour.email_viewer_desc",
|
||||||
|
placement: "left",
|
||||||
|
beforeAction: () => {
|
||||||
|
// Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer
|
||||||
|
const emailList = document.querySelector('[data-tour="email-list"]');
|
||||||
|
if (!emailList) return;
|
||||||
|
// Try to find the welcome email by subject text
|
||||||
|
const items = emailList.querySelectorAll('.cursor-pointer');
|
||||||
|
let target: HTMLElement | null = null;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.textContent?.includes("Welcome to Bulwark Mail")) {
|
||||||
|
target = item as HTMLElement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to first email if welcome email not found
|
||||||
|
if (!target) target = emailList.querySelector('.cursor-pointer') as HTMLElement | null;
|
||||||
|
if (target) target.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keywords",
|
||||||
|
target: '[data-tour="keyword-tags"]',
|
||||||
|
titleKey: "tour.keywords_title",
|
||||||
|
descriptionKey: "tour.keywords_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav-calendar",
|
||||||
|
target: '[data-tour="nav-calendar"]',
|
||||||
|
titleKey: "tour.calendar_title",
|
||||||
|
descriptionKey: "tour.calendar_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav-contacts",
|
||||||
|
target: '[data-tour="nav-contacts"]',
|
||||||
|
titleKey: "tour.contacts_title",
|
||||||
|
descriptionKey: "tour.contacts_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav-settings",
|
||||||
|
target: '[data-tour="nav-settings"]',
|
||||||
|
titleKey: "tour.settings_title",
|
||||||
|
descriptionKey: "tour.settings_desc",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shortcuts",
|
||||||
|
target: '[data-tour="nav-shortcuts"]',
|
||||||
|
titleKey: "tour.shortcuts_title",
|
||||||
|
descriptionKey: "tour.shortcuts_desc",
|
||||||
|
placement: "right",
|
||||||
|
interactive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEMO_TOUR_STEPS: TourStep[] = [
|
||||||
|
{
|
||||||
|
id: "compose-open",
|
||||||
|
target: '[data-tour="composer"]',
|
||||||
|
titleKey: "tour.compose_open_title",
|
||||||
|
descriptionKey: "tour.compose_open_desc",
|
||||||
|
placement: "left",
|
||||||
|
demoOnly: true,
|
||||||
|
beforeAction: () => {
|
||||||
|
// Click the compose button to open the composer
|
||||||
|
const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null;
|
||||||
|
if (btn) btn.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "calendar-view",
|
||||||
|
target: '[data-tour="calendar-view"]',
|
||||||
|
titleKey: "tour.calendar_view_title",
|
||||||
|
descriptionKey: "tour.calendar_view_desc",
|
||||||
|
placement: "bottom",
|
||||||
|
page: "/calendar",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "create-event",
|
||||||
|
target: '[data-tour="create-event-button"]',
|
||||||
|
titleKey: "tour.create_event_title",
|
||||||
|
descriptionKey: "tour.create_event_desc",
|
||||||
|
placement: "bottom",
|
||||||
|
page: "/calendar",
|
||||||
|
interactive: true,
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "event-modal",
|
||||||
|
target: '[data-tour="event-modal"]',
|
||||||
|
titleKey: "tour.event_modal_title",
|
||||||
|
descriptionKey: "tour.event_modal_desc",
|
||||||
|
placement: "left",
|
||||||
|
page: "/calendar",
|
||||||
|
interactive: true,
|
||||||
|
demoOnly: true,
|
||||||
|
beforeAction: () => {
|
||||||
|
// Click the create event button to open the modal
|
||||||
|
const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null;
|
||||||
|
if (btn) btn.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contacts-list",
|
||||||
|
target: '[data-tour="contacts-list"]',
|
||||||
|
titleKey: "tour.contacts_list_title",
|
||||||
|
descriptionKey: "tour.contacts_list_desc",
|
||||||
|
placement: "right",
|
||||||
|
page: "/contacts",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings-tabs",
|
||||||
|
target: '[data-tour="settings-tabs"]',
|
||||||
|
titleKey: "tour.settings_tabs_title",
|
||||||
|
descriptionKey: "tour.settings_tabs_desc",
|
||||||
|
placement: "right",
|
||||||
|
page: "/settings",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nav-files",
|
||||||
|
target: '[data-tour="nav-files"]',
|
||||||
|
titleKey: "tour.files_title",
|
||||||
|
descriptionKey: "tour.files_desc",
|
||||||
|
placement: "right",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-banner",
|
||||||
|
target: '[data-tour="demo-banner"]',
|
||||||
|
titleKey: "tour.demo_banner_title",
|
||||||
|
descriptionKey: "tour.demo_banner_desc",
|
||||||
|
placement: "bottom",
|
||||||
|
page: "/",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quota",
|
||||||
|
target: '[data-tour="storage-quota"]',
|
||||||
|
titleKey: "tour.quota_title",
|
||||||
|
descriptionKey: "tour.quota_desc",
|
||||||
|
placement: "right",
|
||||||
|
demoOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTourSteps(options: {
|
||||||
|
isDemoMode: boolean;
|
||||||
|
supportsCalendar: boolean;
|
||||||
|
supportsWebDAV: boolean;
|
||||||
|
}): TourStep[] {
|
||||||
|
let steps = [...BASE_TOUR_STEPS];
|
||||||
|
|
||||||
|
if (!options.supportsCalendar) {
|
||||||
|
steps = steps.filter((s) => s.id !== "nav-calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isDemoMode) {
|
||||||
|
const demoSteps = DEMO_TOUR_STEPS.filter((s) => {
|
||||||
|
if (s.id === "nav-files" && !options.supportsWebDAV) return false;
|
||||||
|
if ((s.id === "calendar-view" || s.id === "create-event" || s.id === "event-modal") && !options.supportsCalendar) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
steps = [...steps, ...demoSteps];
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Lightbulb, Settings } from "lucide-react";
|
import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
|
||||||
const ONBOARDING_KEY = "onboarding_completed";
|
const ONBOARDING_KEY = "onboarding_completed";
|
||||||
|
|
||||||
export function WelcomeBanner() {
|
export function WelcomeBanner() {
|
||||||
const t = useTranslations("welcome");
|
const t = useTranslations("welcome");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { startTour } = useTour();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
@@ -78,6 +80,15 @@ export function WelcomeBanner() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2.5 flex justify-end gap-2">
|
<div className="mt-2.5 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { dismiss(); startTour(); }}
|
||||||
|
className="text-xs h-7"
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{t("start_tour")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface ConfigData {
|
|||||||
loginImprintUrl: string;
|
loginImprintUrl: string;
|
||||||
loginPrivacyPolicyUrl: string;
|
loginPrivacyPolicyUrl: string;
|
||||||
loginWebsiteUrl: string;
|
loginWebsiteUrl: string;
|
||||||
|
demoMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppConfig extends ConfigData {
|
interface AppConfig extends ConfigData {
|
||||||
@@ -91,6 +92,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: configCache?.loginImprintUrl || '',
|
loginImprintUrl: configCache?.loginImprintUrl || '',
|
||||||
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
||||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||||
|
demoMode: configCache?.demoMode || false,
|
||||||
isLoading: !configCache,
|
isLoading: !configCache,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -118,6 +120,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: configCache.loginImprintUrl,
|
loginImprintUrl: configCache.loginImprintUrl,
|
||||||
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
||||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||||
|
demoMode: configCache.demoMode,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -146,6 +149,7 @@ export function useConfig(): AppConfig {
|
|||||||
loginImprintUrl: data.loginImprintUrl,
|
loginImprintUrl: data.loginImprintUrl,
|
||||||
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
||||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||||
|
demoMode: data.demoMode,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,800 @@
|
|||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from '@/lib/jmap/types';
|
||||||
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
|
import { getDemoData, type DemoData } from './demo-data';
|
||||||
|
import { generateDemoId } from './demo-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory JMAP client for demo mode.
|
||||||
|
* All data lives in memory — no network calls, no cookies.
|
||||||
|
*/
|
||||||
|
export class DemoJMAPClient implements IJMAPClient {
|
||||||
|
private data: DemoData;
|
||||||
|
private blobStore = new Map<string, Blob>();
|
||||||
|
private connectionCallback: ((connected: boolean) => void) | null = null;
|
||||||
|
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||||
|
private lastStates: AccountStates = {};
|
||||||
|
private incomingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.data = getDemoData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection lifecycle ──────────────────────────────────────
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
// Start simulated incoming email timer
|
||||||
|
this.startIncomingEmailTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.stopIncomingEmailTimer();
|
||||||
|
this.connectionCallback = null;
|
||||||
|
this.stateChangeCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect(): Promise<void> { /* no-op */ }
|
||||||
|
async ping(): Promise<void> { /* no-op */ }
|
||||||
|
|
||||||
|
// ── Session / auth accessors ──────────────────────────────────
|
||||||
|
|
||||||
|
getServerUrl(): string { return 'https://demo.example.com'; }
|
||||||
|
getAuthHeader(): string { return 'Bearer demo-token'; }
|
||||||
|
updateAccessToken(): void { /* no-op */ }
|
||||||
|
getAccountId(): string { return 'demo-account'; }
|
||||||
|
getUsername(): string { return 'demo@example.com'; }
|
||||||
|
|
||||||
|
// ── Capabilities ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
getCapabilities(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||||
|
'urn:ietf:params:jmap:mail': {},
|
||||||
|
'urn:ietf:params:jmap:submission': {},
|
||||||
|
'urn:ietf:params:jmap:vacationresponse': {},
|
||||||
|
'urn:ietf:params:jmap:contacts': {},
|
||||||
|
'urn:ietf:params:jmap:calendars': {},
|
||||||
|
'urn:ietf:params:jmap:sieve': {},
|
||||||
|
'urn:ietf:params:jmap:quota': {},
|
||||||
|
'urn:ietf:params:jmap:files': {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaxSizeUpload(): number { return 50_000_000; }
|
||||||
|
getMaxCallsInRequest(): number { return 16; }
|
||||||
|
getMaxObjectsInGet(): number { return 500; }
|
||||||
|
getEventSourceUrl(): string | null { return null; }
|
||||||
|
supportsEmailSubmission(): boolean { return true; }
|
||||||
|
supportsQuota(): boolean { return true; }
|
||||||
|
supportsVacationResponse(): boolean { return true; }
|
||||||
|
supportsContacts(): boolean { return true; }
|
||||||
|
supportsCalendars(): boolean { return true; }
|
||||||
|
supportsSieve(): boolean { return true; }
|
||||||
|
supportsFiles(): boolean { return true; }
|
||||||
|
|
||||||
|
// ── Push / state ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
setupPushNotifications(): boolean { return true; }
|
||||||
|
closePushNotifications(): void { /* no-op in demo */ }
|
||||||
|
onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; }
|
||||||
|
onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; }
|
||||||
|
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
||||||
|
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
||||||
|
|
||||||
|
// ── Quota ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getQuota(): Promise<{ used: number; total: number } | null> {
|
||||||
|
return { used: 245_366_784, total: 1_073_741_824 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mailboxes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||||
|
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||||
|
|
||||||
|
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
||||||
|
const mb: Mailbox = {
|
||||||
|
id: generateDemoId('mailbox'),
|
||||||
|
name,
|
||||||
|
sortOrder: 100,
|
||||||
|
totalEmails: 0,
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalThreads: 0,
|
||||||
|
unreadThreads: 0,
|
||||||
|
parentId,
|
||||||
|
isSubscribed: true,
|
||||||
|
myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true },
|
||||||
|
};
|
||||||
|
this.data.mailboxes.push(mb);
|
||||||
|
return mb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void> {
|
||||||
|
const mb = this.data.mailboxes.find(m => m.id === mailboxId);
|
||||||
|
if (mb) Object.assign(mb, changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMailbox(mailboxId: string): Promise<void> {
|
||||||
|
this.data.mailboxes = this.data.mailboxes.filter(m => m.id !== mailboxId);
|
||||||
|
// Also remove emails in this mailbox
|
||||||
|
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Emails ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||||
|
let filtered = this.data.emails;
|
||||||
|
if (mailboxId) {
|
||||||
|
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||||
|
}
|
||||||
|
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||||
|
const total = filtered.length;
|
||||||
|
const emails = filtered.slice(position, position + limit);
|
||||||
|
return { emails, hasMore: position + limit < total, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
|
||||||
|
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmail(emailId: string): Promise<Email | null> {
|
||||||
|
return this.data.emails.find(e => e.id === emailId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
||||||
|
const result: Record<string, { total: number; unread: number }> = {};
|
||||||
|
for (const tagId of tagIds) {
|
||||||
|
const tagged = this.data.emails.filter(e => e.keywords[tagId]);
|
||||||
|
result[tagId] = {
|
||||||
|
total: tagged.length,
|
||||||
|
unread: tagged.filter(e => !e.keywords.$seen).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchEmails(query: string, mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
let filtered = this.data.emails.filter(e => {
|
||||||
|
const text = [e.subject, e.preview, e.from?.[0]?.name, e.from?.[0]?.email].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return text.includes(q);
|
||||||
|
});
|
||||||
|
if (mailboxId) filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||||
|
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||||
|
const total = filtered.length;
|
||||||
|
const emails = filtered.slice(position, position + limit);
|
||||||
|
return { emails, hasMore: position + limit < total, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async advancedSearchEmails(filter: Record<string, unknown>, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||||
|
// Simplified: just return all emails for any advanced filter
|
||||||
|
let filtered = [...this.data.emails];
|
||||||
|
if (filter.inMailbox) filtered = filtered.filter(e => e.mailboxIds[filter.inMailbox as string]);
|
||||||
|
if (filter.text) {
|
||||||
|
const q = (filter.text as string).toLowerCase();
|
||||||
|
filtered = filtered.filter(e => [e.subject, e.preview].filter(Boolean).join(' ').toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||||
|
const total = filtered.length;
|
||||||
|
const emails = filtered.slice(position, position + limit);
|
||||||
|
return { emails, hasMore: position + limit < total, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email mutations ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async markAsRead(emailId: string, read: boolean = true): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (!email) return;
|
||||||
|
if (read) {
|
||||||
|
email.keywords.$seen = true;
|
||||||
|
} else {
|
||||||
|
delete email.keywords.$seen;
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
|
||||||
|
for (const id of emailIds) {
|
||||||
|
const email = this.data.emails.find(e => e.id === id);
|
||||||
|
if (email) {
|
||||||
|
if (read) email.keywords.$seen = true;
|
||||||
|
else delete email.keywords.$seen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleStar(emailId: string, starred: boolean): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (!email) return;
|
||||||
|
if (starred) email.keywords.$flagged = true;
|
||||||
|
else delete email.keywords.$flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (email) email.keywords = { ...email.keywords, ...keywords };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmail(emailId: string): Promise<void> {
|
||||||
|
this.data.emails = this.data.emails.filter(e => e.id !== emailId);
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (!email) return;
|
||||||
|
email.mailboxIds = { [trashMailboxId]: true };
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDeleteEmails(emailIds: string[]): Promise<void> {
|
||||||
|
const idSet = new Set(emailIds);
|
||||||
|
this.data.emails = this.data.emails.filter(e => !idSet.has(e.id));
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
|
||||||
|
for (const id of emailIds) {
|
||||||
|
const email = this.data.emails.find(e => e.id === id);
|
||||||
|
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async emptyMailbox(mailboxId: string): Promise<number> {
|
||||||
|
const before = this.data.emails.length;
|
||||||
|
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||||
|
const removed = before - this.data.emails.length;
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsSpam(emailId: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
||||||
|
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true };
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async undoSpam(emailId: string, originalMailboxId: string): Promise<void> {
|
||||||
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
|
if (email) email.mailboxIds = { [originalMailboxId]: true };
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Threads ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getThread(threadId: string): Promise<Thread | null> {
|
||||||
|
const emails = this.data.emails.filter(e => e.threadId === threadId);
|
||||||
|
if (emails.length === 0) return null;
|
||||||
|
return { id: threadId, emailIds: emails.map(e => e.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getThreadEmails(threadId: string): Promise<Email[]> {
|
||||||
|
return this.data.emails
|
||||||
|
.filter(e => e.threadId === threadId)
|
||||||
|
.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compose / Send ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createDraft(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
_identityId?: string,
|
||||||
|
_fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||||
|
_fromName?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||||
|
const id = draftId || generateDemoId('email');
|
||||||
|
const existing = draftId ? this.data.emails.findIndex(e => e.id === draftId) : -1;
|
||||||
|
|
||||||
|
const email: Email = {
|
||||||
|
id, threadId: generateDemoId('thread'),
|
||||||
|
mailboxIds: { [draftsMb?.id || 'demo-mailbox-drafts']: true },
|
||||||
|
keywords: { $seen: true, $draft: true },
|
||||||
|
size: body.length,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: to.map(e => ({ email: e })),
|
||||||
|
cc: cc?.map(e => ({ email: e })),
|
||||||
|
bcc: bcc?.map(e => ({ email: e })),
|
||||||
|
subject,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
preview: body.substring(0, 200),
|
||||||
|
hasAttachment: !!attachments?.length,
|
||||||
|
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||||
|
htmlBody: [],
|
||||||
|
bodyValues: { '1': { value: body } },
|
||||||
|
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||||
|
messageId: `<${id}@demo.example.com>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing >= 0) {
|
||||||
|
this.data.emails[existing] = email;
|
||||||
|
} else {
|
||||||
|
this.data.emails.push(email);
|
||||||
|
}
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmail(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
_identityId?: string,
|
||||||
|
_fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
_fromName?: string,
|
||||||
|
htmlBody?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||||
|
): Promise<void> {
|
||||||
|
// Remove draft if updating
|
||||||
|
if (draftId) {
|
||||||
|
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
||||||
|
}
|
||||||
|
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
||||||
|
const email: Email = {
|
||||||
|
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
||||||
|
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: body.length + (htmlBody?.length || 0),
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: to.map(e => ({ email: e })),
|
||||||
|
cc: cc?.map(e => ({ email: e })),
|
||||||
|
bcc: bcc?.map(e => ({ email: e })),
|
||||||
|
subject,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
preview: body.substring(0, 200),
|
||||||
|
hasAttachment: !!attachments?.length,
|
||||||
|
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||||
|
htmlBody: htmlBody ? [{ partId: '2', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }] : [],
|
||||||
|
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
|
||||||
|
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||||
|
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
|
||||||
|
};
|
||||||
|
this.data.emails.push(email);
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
||||||
|
async sendImipInvitation(): Promise<void> { /* no-op in demo */ }
|
||||||
|
async sendImipCancellation(): Promise<void> { /* no-op in demo */ }
|
||||||
|
|
||||||
|
// ── Blobs ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||||
|
const blobId = generateDemoId('blob');
|
||||||
|
this.blobStore.set(blobId, file);
|
||||||
|
return { blobId, size: file.size, type: file.type };
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlobDownloadUrl(blobId: string): string {
|
||||||
|
return `data:application/octet-stream;demo-blob=${blobId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchBlob(blobId: string): Promise<Blob> {
|
||||||
|
return this.blobStore.get(blobId) ?? new Blob(['[Demo placeholder content]'], { type: 'text/plain' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchBlobAsObjectUrl(blobId: string): Promise<string> {
|
||||||
|
const blob = await this.fetchBlob(blobId);
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchBlobArrayBuffer(blobId: string): Promise<ArrayBuffer> {
|
||||||
|
const blob = await this.fetchBlob(blobId);
|
||||||
|
return blob.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadBlob(blobId: string, name?: string): Promise<void> {
|
||||||
|
const blob = await this.fetchBlob(blobId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name || 'download';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Identities ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getIdentities(): Promise<Identity[]> { return [...this.data.identities]; }
|
||||||
|
|
||||||
|
async createIdentity(
|
||||||
|
name: string, email: string,
|
||||||
|
replyTo?: EmailAddress[] | null, bcc?: EmailAddress[] | null,
|
||||||
|
htmlSignature?: string, textSignature?: string,
|
||||||
|
): Promise<Identity> {
|
||||||
|
const identity: Identity = {
|
||||||
|
id: generateDemoId('identity'), name, email,
|
||||||
|
replyTo: replyTo ?? undefined, bcc: bcc ?? undefined,
|
||||||
|
htmlSignature: htmlSignature ?? '', textSignature: textSignature ?? '',
|
||||||
|
mayDelete: true,
|
||||||
|
};
|
||||||
|
this.data.identities.push(identity);
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIdentity(identityId: string, updates: { name?: string; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; htmlSignature?: string; textSignature?: string }): Promise<void> {
|
||||||
|
const identity = this.data.identities.find(i => i.id === identityId);
|
||||||
|
if (identity) Object.assign(identity, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIdentity(identityId: string): Promise<void> {
|
||||||
|
this.data.identities = this.data.identities.filter(i => i.id !== identityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vacation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getVacationResponse(): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
|
||||||
|
|
||||||
|
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
|
||||||
|
Object.assign(this.data.vacationResponse, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contacts ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getContactsAccountId(): string { return 'demo-account'; }
|
||||||
|
|
||||||
|
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||||
|
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||||
|
|
||||||
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||||
|
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||||
|
return [...this.data.contacts];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllContacts(): Promise<ContactCard[]> { return [...this.data.contacts]; }
|
||||||
|
|
||||||
|
async getContact(contactId: string): Promise<ContactCard | null> {
|
||||||
|
return this.data.contacts.find(c => c.id === contactId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
|
||||||
|
const full: ContactCard = {
|
||||||
|
id: generateDemoId('contact'),
|
||||||
|
addressBookIds: contact.addressBookIds ?? { 'demo-addressbook-personal': true },
|
||||||
|
...contact,
|
||||||
|
} as ContactCard;
|
||||||
|
this.data.contacts.push(full);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
|
||||||
|
const contact = this.data.contacts.find(c => c.id === contactId);
|
||||||
|
if (contact) Object.assign(contact, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteContact(contactId: string): Promise<void> {
|
||||||
|
this.data.contacts = this.data.contacts.filter(c => c.id !== contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchContacts(query: string): Promise<ContactCard[]> {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return this.data.contacts.filter(c => {
|
||||||
|
const nameStr = c.name?.components?.map(nc => nc.value).join(' ').toLowerCase() ?? '';
|
||||||
|
const emailStr = Object.values(c.emails ?? {}).map(e => e.address).join(' ').toLowerCase();
|
||||||
|
return nameStr.includes(q) || emailStr.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Calendars ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getCalendarsAccountId(): string { return 'demo-account'; }
|
||||||
|
|
||||||
|
async getCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
|
||||||
|
async getAllCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
|
||||||
|
|
||||||
|
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
|
||||||
|
const full: Calendar = {
|
||||||
|
id: generateDemoId('calendar'),
|
||||||
|
name: calendar.name ?? 'New Calendar',
|
||||||
|
description: calendar.description ?? null,
|
||||||
|
color: calendar.color ?? '#6366f1',
|
||||||
|
sortOrder: calendar.sortOrder ?? 99,
|
||||||
|
isSubscribed: true, isVisible: true, isDefault: false,
|
||||||
|
includeInAvailability: 'all',
|
||||||
|
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null, shareWith: null,
|
||||||
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||||
|
...calendar,
|
||||||
|
} as Calendar;
|
||||||
|
this.data.calendars.push(full);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
|
||||||
|
const cal = this.data.calendars.find(c => c.id === calendarId);
|
||||||
|
if (cal) Object.assign(cal, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCalendar(calendarId: string): Promise<void> {
|
||||||
|
this.data.calendars = this.data.calendars.filter(c => c.id !== calendarId);
|
||||||
|
this.data.calendarEvents = this.data.calendarEvents.filter(e => !e.calendarIds[calendarId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
|
||||||
|
let events = [...this.data.calendarEvents];
|
||||||
|
if (calendarIds?.length) {
|
||||||
|
events = events.filter(e => calendarIds.some(cid => e.calendarIds[cid]));
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
|
||||||
|
return this.data.calendarEvents.find(e => e.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCalendarEvent(event: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
||||||
|
const full: CalendarEvent = {
|
||||||
|
id: generateDemoId('event'),
|
||||||
|
calendarIds: event.calendarIds ?? { 'demo-calendar-personal': true },
|
||||||
|
'@type': 'Event',
|
||||||
|
uid: generateDemoId('uid'),
|
||||||
|
title: event.title ?? 'New Event',
|
||||||
|
description: event.description ?? '',
|
||||||
|
descriptionContentType: 'text/plain',
|
||||||
|
isDraft: false, isOrigin: true,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
sequence: 0,
|
||||||
|
start: event.start ?? new Date().toISOString(),
|
||||||
|
duration: event.duration ?? 'PT1H',
|
||||||
|
timeZone: event.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
utcStart: event.utcStart ?? null,
|
||||||
|
utcEnd: event.utcEnd ?? null,
|
||||||
|
showWithoutTime: event.showWithoutTime ?? false,
|
||||||
|
status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public',
|
||||||
|
color: null, keywords: null, categories: null, locale: null,
|
||||||
|
replyTo: null, organizerCalendarAddress: null, participants: null,
|
||||||
|
mayInviteSelf: false, mayInviteOthers: false, hideAttendees: false,
|
||||||
|
recurrenceId: null, recurrenceIdTimeZone: null, recurrenceRules: null,
|
||||||
|
recurrenceOverrides: null, excludedRecurrenceRules: null,
|
||||||
|
useDefaultAlerts: true, alerts: null, locations: null,
|
||||||
|
virtualLocations: null, links: null, relatedTo: null,
|
||||||
|
...event,
|
||||||
|
} as CalendarEvent;
|
||||||
|
this.data.calendarEvents.push(full);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
|
||||||
|
const event = this.data.calendarEvents.find(e => e.id === eventId);
|
||||||
|
if (!event) throw new Error('Event not found');
|
||||||
|
Object.assign(event, updates, { updated: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCalendarEvent(eventId: string): Promise<void> {
|
||||||
|
this.data.calendarEvents = this.data.calendarEvents.filter(e => e.id !== eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||||
|
const idSet = new Set(eventIds);
|
||||||
|
this.data.calendarEvents = this.data.calendarEvents.filter(e => !idSet.has(e.id));
|
||||||
|
return { destroyed: eventIds, notDestroyed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||||
|
return this.data.calendarEvents.filter(e => {
|
||||||
|
if (filter.after && e.start < filter.after) return false;
|
||||||
|
if (filter.before && e.start > filter.before) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAllCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||||
|
return this.queryCalendarEvents(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
|
||||||
|
return []; // no-op in demo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
|
|
||||||
|
getSieveAccountId(): string { return 'demo-account'; }
|
||||||
|
|
||||||
|
getSieveCapabilities(): SieveCapabilities | null {
|
||||||
|
return { ...this.data.sieveCapabilities };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSieveScripts(): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
|
||||||
|
|
||||||
|
async getSieveScriptContent(blobId: string): Promise<string> {
|
||||||
|
return this.data.sieveContent[blobId] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
|
||||||
|
const blobId = generateDemoId('sieve-blob');
|
||||||
|
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
|
||||||
|
this.data.sieveScripts.push(script);
|
||||||
|
this.data.sieveContent[blobId] = content;
|
||||||
|
if (activate) {
|
||||||
|
for (const s of this.data.sieveScripts) {
|
||||||
|
if (s.id !== script.id) s.isActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
|
||||||
|
const script = this.data.sieveScripts.find(s => s.id === scriptId);
|
||||||
|
if (!script) return;
|
||||||
|
const blobId = generateDemoId('sieve-blob');
|
||||||
|
this.data.sieveContent[blobId] = content;
|
||||||
|
script.blobId = blobId;
|
||||||
|
if (activate !== undefined) {
|
||||||
|
script.isActive = activate;
|
||||||
|
if (activate) {
|
||||||
|
for (const s of this.data.sieveScripts) {
|
||||||
|
if (s.id !== scriptId) s.isActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSieveScript(scriptId: string): Promise<void> {
|
||||||
|
this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Files (FileNode) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
getFilesAccountId(): string { return 'demo-account'; }
|
||||||
|
|
||||||
|
async probeFileNodeSupport(): Promise<boolean> { return true; }
|
||||||
|
|
||||||
|
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
||||||
|
return this.data.fileNodes.filter(n => n.parentId === parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||||
|
if (ids === null) return [...this.data.fileNodes];
|
||||||
|
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||||||
|
const node: FileNode = {
|
||||||
|
id: generateDemoId('file'),
|
||||||
|
parentId, name, type: 'd', blobId: null, size: 0,
|
||||||
|
created: new Date().toISOString(), updated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.data.fileNodes.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
||||||
|
const node: FileNode = {
|
||||||
|
id: generateDemoId('file'),
|
||||||
|
parentId, name, type, blobId, size,
|
||||||
|
created: new Date().toISOString(), updated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.data.fileNodes.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||||||
|
const node = this.data.fileNodes.find(n => n.id === id);
|
||||||
|
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
|
||||||
|
return { destroyed: ids, notDestroyed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
||||||
|
const original = this.data.fileNodes.find(n => n.id === id);
|
||||||
|
if (!original) throw new Error('File node not found');
|
||||||
|
return this.createFileNode(newName, original.blobId ?? '', original.type, original.size, parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
|
|
||||||
|
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||||
|
async submitEmail(): Promise<void> { /* no-op */ }
|
||||||
|
async sendRawEmail(): Promise<void> { /* no-op */ }
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private recalcMailboxCounts(): void {
|
||||||
|
for (const mb of this.data.mailboxes) {
|
||||||
|
const inMb = this.data.emails.filter(e => e.mailboxIds[mb.id]);
|
||||||
|
mb.totalEmails = inMb.length;
|
||||||
|
mb.unreadEmails = inMb.filter(e => !e.keywords.$seen).length;
|
||||||
|
mb.totalThreads = new Set(inMb.map(e => e.threadId)).size;
|
||||||
|
mb.unreadThreads = new Set(inMb.filter(e => !e.keywords.$seen).map(e => e.threadId)).size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startIncomingEmailTimer(): void {
|
||||||
|
this.stopIncomingEmailTimer();
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
const delay = 60_000 + Math.random() * 60_000; // 60-120 seconds
|
||||||
|
this.incomingTimer = setTimeout(() => {
|
||||||
|
this.simulateIncomingEmail();
|
||||||
|
scheduleNext();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopIncomingEmailTimer(): void {
|
||||||
|
if (this.incomingTimer) {
|
||||||
|
clearTimeout(this.incomingTimer);
|
||||||
|
this.incomingTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulateIncomingEmail(): void {
|
||||||
|
const senders = [
|
||||||
|
{ name: 'Alice Johnson', email: 'alice.johnson@example.com' },
|
||||||
|
{ name: 'Bob Chen', email: 'bob.chen@example.com' },
|
||||||
|
{ name: 'Sarah Kim', email: 'sarah.kim@example.com' },
|
||||||
|
{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' },
|
||||||
|
];
|
||||||
|
const subjects = [
|
||||||
|
'Quick question about the project',
|
||||||
|
'Meeting rescheduled to tomorrow',
|
||||||
|
'FYI: Updated documentation',
|
||||||
|
'Can you review this PR?',
|
||||||
|
'Lunch today?',
|
||||||
|
'Important: deadline reminder',
|
||||||
|
];
|
||||||
|
|
||||||
|
const sender = senders[Math.floor(Math.random() * senders.length)];
|
||||||
|
const subject = subjects[Math.floor(Math.random() * subjects.length)];
|
||||||
|
const id = generateDemoId('email');
|
||||||
|
|
||||||
|
const email: Email = {
|
||||||
|
id, threadId: generateDemoId('thread'),
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 1800,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [sender],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject, sentAt: new Date().toISOString(),
|
||||||
|
preview: `Hi, ${subject.toLowerCase()}. Let me know what you think.`,
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: 120, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: `Hi,\n\n${subject}. Let me know what you think.\n\nBest,\n${sender.name}` },
|
||||||
|
},
|
||||||
|
messageId: `<${id}@demo.example.com>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.data.emails.unshift(email);
|
||||||
|
this.recalcMailboxCounts();
|
||||||
|
|
||||||
|
// Notify state change to trigger UI refresh
|
||||||
|
this.stateChangeCallback?.({
|
||||||
|
'@type': 'StateChange',
|
||||||
|
changed: { 'demo-account': { Email: generateDemoId('state'), Mailbox: generateDemoId('state') } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { cloneFixtures } from './demo-utils';
|
||||||
|
import { createDemoMailboxes } from './fixtures/mailboxes';
|
||||||
|
import { createDemoEmails } from './fixtures/emails';
|
||||||
|
import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts';
|
||||||
|
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
|
||||||
|
import { createDemoIdentities } from './fixtures/identities';
|
||||||
|
import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveContent } from './fixtures/filters';
|
||||||
|
import { createDemoFileNodes } from './fixtures/files';
|
||||||
|
import { createDemoVacationResponse } from './fixtures/vacation';
|
||||||
|
|
||||||
|
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
|
||||||
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
|
|
||||||
|
export interface DemoData {
|
||||||
|
mailboxes: Mailbox[];
|
||||||
|
emails: Email[];
|
||||||
|
contacts: ContactCard[];
|
||||||
|
addressBooks: AddressBook[];
|
||||||
|
calendars: Calendar[];
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
identities: Identity[];
|
||||||
|
sieveScripts: SieveScript[];
|
||||||
|
sieveCapabilities: SieveCapabilities;
|
||||||
|
sieveContent: Record<string, string>;
|
||||||
|
fileNodes: FileNode[];
|
||||||
|
vacationResponse: VacationResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a fresh deep-cloned copy of all demo data. */
|
||||||
|
export function getDemoData(): DemoData {
|
||||||
|
return cloneFixtures({
|
||||||
|
mailboxes: createDemoMailboxes(),
|
||||||
|
emails: createDemoEmails(),
|
||||||
|
contacts: createDemoContacts(),
|
||||||
|
addressBooks: createDemoAddressBooks(),
|
||||||
|
calendars: createDemoCalendars(),
|
||||||
|
calendarEvents: createDemoCalendarEvents(),
|
||||||
|
identities: createDemoIdentities(),
|
||||||
|
sieveScripts: createDemoSieveScripts(),
|
||||||
|
sieveCapabilities: createDemoSieveCapabilities(),
|
||||||
|
sieveContent: createDemoSieveContent(),
|
||||||
|
fileNodes: createDemoFileNodes(),
|
||||||
|
vacationResponse: createDemoVacationResponse(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
let demoIdCounter = 0;
|
||||||
|
|
||||||
|
/** Generate a unique demo ID with the given prefix. */
|
||||||
|
export function generateDemoId(prefix: string = 'demo'): string {
|
||||||
|
return `${prefix}-${Date.now()}-${++demoIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an ISO date string relative to "now".
|
||||||
|
* @param daysOffset — whole days from today
|
||||||
|
* @param hoursOffset — additional hours offset (default 0)
|
||||||
|
* @param minutesOffset — additional minutes offset (default 0)
|
||||||
|
*/
|
||||||
|
export function demoDate(daysOffset: number, hoursOffset: number = 0, minutesOffset: number = 0): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + daysOffset);
|
||||||
|
d.setHours(d.getHours() + hoursOffset, d.getMinutes() + minutesOffset, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a local date-time string (YYYY-MM-DDTHH:mm:ss) for JSCalendar "start" fields.
|
||||||
|
*/
|
||||||
|
export function demoISODate(daysOffset: number, hours: number = 0, minutes: number = 0): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + daysOffset);
|
||||||
|
d.setHours(hours, minutes, 0, 0);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep clone fixture data so in-memory mutations don't corrupt originals. */
|
||||||
|
export function cloneFixtures<T>(data: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
import { demoDate, demoISODate } from '../demo-utils';
|
||||||
|
|
||||||
|
export function createDemoCalendars(): Calendar[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-calendar-personal',
|
||||||
|
name: 'Personal',
|
||||||
|
description: null,
|
||||||
|
color: '#3b82f6',
|
||||||
|
sortOrder: 1,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: true,
|
||||||
|
includeInAvailability: 'all',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-calendar-work',
|
||||||
|
name: 'Work',
|
||||||
|
description: null,
|
||||||
|
color: '#22c55e',
|
||||||
|
sortOrder: 2,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: false,
|
||||||
|
includeInAvailability: 'all',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-calendar-birthdays',
|
||||||
|
name: 'Birthdays',
|
||||||
|
description: null,
|
||||||
|
color: '#eab308',
|
||||||
|
sortOrder: 3,
|
||||||
|
isSubscribed: true,
|
||||||
|
isVisible: true,
|
||||||
|
isDefault: false,
|
||||||
|
includeInAvailability: 'none',
|
||||||
|
defaultAlertsWithTime: null,
|
||||||
|
defaultAlertsWithoutTime: null,
|
||||||
|
timeZone: null,
|
||||||
|
shareWith: null,
|
||||||
|
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDemoCalendarEvents(): CalendarEvent[] {
|
||||||
|
const baseEvent = {
|
||||||
|
'@type': 'Event' as const,
|
||||||
|
descriptionContentType: 'text/plain',
|
||||||
|
isDraft: false,
|
||||||
|
isOrigin: true,
|
||||||
|
sequence: 0,
|
||||||
|
status: 'confirmed' as const,
|
||||||
|
freeBusyStatus: 'busy' as const,
|
||||||
|
privacy: 'public' as const,
|
||||||
|
color: null,
|
||||||
|
keywords: null,
|
||||||
|
categories: null,
|
||||||
|
locale: null,
|
||||||
|
replyTo: null,
|
||||||
|
organizerCalendarAddress: null,
|
||||||
|
participants: null,
|
||||||
|
mayInviteSelf: false,
|
||||||
|
mayInviteOthers: false,
|
||||||
|
hideAttendees: false,
|
||||||
|
recurrenceId: null,
|
||||||
|
recurrenceIdTimeZone: null,
|
||||||
|
recurrenceRules: null,
|
||||||
|
recurrenceOverrides: null,
|
||||||
|
excludedRecurrenceRules: null,
|
||||||
|
useDefaultAlerts: true,
|
||||||
|
alerts: null,
|
||||||
|
locations: null,
|
||||||
|
virtualLocations: null,
|
||||||
|
links: null,
|
||||||
|
relatedTo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ── Personal calendar ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-1',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
uid: 'demo-event-1@example.com',
|
||||||
|
title: 'Dentist Appointment',
|
||||||
|
description: 'Regular checkup at Dr. Smith\'s office',
|
||||||
|
created: demoDate(-7),
|
||||||
|
updated: demoDate(-7),
|
||||||
|
start: demoISODate(2, 10, 0),
|
||||||
|
utcStart: demoDate(2, 10),
|
||||||
|
utcEnd: demoDate(2, 11),
|
||||||
|
duration: 'PT1H',
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
showWithoutTime: false,
|
||||||
|
locations: { loc1: { '@type': 'Location', name: 'Dr. Smith Dental Clinic', description: '123 Medical Plaza', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-2',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
uid: 'demo-event-2@example.com',
|
||||||
|
title: 'Birthday Party',
|
||||||
|
description: 'Emma\'s birthday celebration',
|
||||||
|
created: demoDate(-10),
|
||||||
|
updated: demoDate(-10),
|
||||||
|
start: demoISODate(5),
|
||||||
|
utcStart: demoDate(5),
|
||||||
|
utcEnd: demoDate(6),
|
||||||
|
duration: 'P1D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
freeBusyStatus: 'free' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-3',
|
||||||
|
calendarIds: { 'demo-calendar-personal': true },
|
||||||
|
uid: 'demo-event-3@example.com',
|
||||||
|
title: 'Weekend Trip',
|
||||||
|
description: 'Road trip to the mountains',
|
||||||
|
created: demoDate(-5),
|
||||||
|
updated: demoDate(-5),
|
||||||
|
start: demoISODate(8),
|
||||||
|
utcStart: demoDate(8),
|
||||||
|
utcEnd: demoDate(10),
|
||||||
|
duration: 'P2D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
freeBusyStatus: 'busy' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Work calendar ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-4',
|
||||||
|
calendarIds: { 'demo-calendar-work': true },
|
||||||
|
uid: 'demo-event-4@example.com',
|
||||||
|
title: 'Weekly Standup',
|
||||||
|
description: 'Team sync-up meeting',
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-1),
|
||||||
|
start: demoISODate(1, 9, 30),
|
||||||
|
utcStart: demoDate(1, 9, 30),
|
||||||
|
utcEnd: demoDate(1, 10, 0),
|
||||||
|
duration: 'PT30M',
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
showWithoutTime: false,
|
||||||
|
recurrenceRules: [{
|
||||||
|
'@type': 'RecurrenceRule',
|
||||||
|
frequency: 'weekly',
|
||||||
|
interval: 1,
|
||||||
|
rscale: 'gregorian',
|
||||||
|
skip: 'omit',
|
||||||
|
firstDayOfWeek: 'mo',
|
||||||
|
byDay: [{ day: 'mo' }],
|
||||||
|
byMonthDay: null,
|
||||||
|
byMonth: null,
|
||||||
|
byYearDay: null,
|
||||||
|
byWeekNo: null,
|
||||||
|
byHour: null,
|
||||||
|
byMinute: null,
|
||||||
|
bySecond: null,
|
||||||
|
bySetPosition: null,
|
||||||
|
count: null,
|
||||||
|
until: null,
|
||||||
|
}],
|
||||||
|
virtualLocations: { vl1: { '@type': 'VirtualLocation', name: 'Zoom', uri: 'https://zoom.example/123456', description: 'Weekly standup room', features: null } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-5',
|
||||||
|
calendarIds: { 'demo-calendar-work': true },
|
||||||
|
uid: 'demo-event-5@example.com',
|
||||||
|
title: 'Quarterly Review',
|
||||||
|
description: 'Q4 performance review and planning session',
|
||||||
|
created: demoDate(-14),
|
||||||
|
updated: demoDate(-3),
|
||||||
|
start: demoISODate(4, 14, 0),
|
||||||
|
utcStart: demoDate(4, 14),
|
||||||
|
utcEnd: demoDate(4, 16),
|
||||||
|
duration: 'PT2H',
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
showWithoutTime: false,
|
||||||
|
participants: {
|
||||||
|
p1: {
|
||||||
|
'@type': 'Participant', name: 'Demo User', email: 'demo@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||||
|
kind: 'individual', roles: { attendee: true }, participationStatus: 'accepted', participationComment: null,
|
||||||
|
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||||
|
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||||
|
locationId: null, language: null, links: null,
|
||||||
|
},
|
||||||
|
p2: {
|
||||||
|
'@type': 'Participant', name: 'Alice Johnson', email: 'alice.johnson@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||||
|
kind: 'individual', roles: { owner: true }, participationStatus: 'accepted', participationComment: null,
|
||||||
|
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||||
|
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||||
|
locationId: null, language: null, links: null,
|
||||||
|
},
|
||||||
|
p3: {
|
||||||
|
'@type': 'Participant', name: 'Bob Chen', email: 'bob.chen@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||||
|
kind: 'individual', roles: { attendee: true }, participationStatus: 'tentative', participationComment: null,
|
||||||
|
expectReply: true, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||||
|
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||||
|
locationId: null, language: null, links: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-6',
|
||||||
|
calendarIds: { 'demo-calendar-work': true },
|
||||||
|
uid: 'demo-event-6@example.com',
|
||||||
|
title: 'Lunch Meeting with Sarah',
|
||||||
|
description: 'Design review over lunch',
|
||||||
|
created: demoDate(-3),
|
||||||
|
updated: demoDate(-3),
|
||||||
|
start: demoISODate(3, 12, 0),
|
||||||
|
utcStart: demoDate(3, 12),
|
||||||
|
utcEnd: demoDate(3, 13),
|
||||||
|
duration: 'PT1H',
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
showWithoutTime: false,
|
||||||
|
locations: { loc1: { '@type': 'Location', name: 'The Garden Bistro', description: '123 Oak Street', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Birthdays calendar ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-7',
|
||||||
|
calendarIds: { 'demo-calendar-birthdays': true },
|
||||||
|
uid: 'demo-event-7@example.com',
|
||||||
|
title: 'Alice Johnson\'s Birthday',
|
||||||
|
description: '',
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-30),
|
||||||
|
start: demoISODate(12),
|
||||||
|
utcStart: demoDate(12),
|
||||||
|
utcEnd: demoDate(13),
|
||||||
|
duration: 'P1D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
freeBusyStatus: 'free' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseEvent,
|
||||||
|
id: 'demo-event-8',
|
||||||
|
calendarIds: { 'demo-calendar-birthdays': true },
|
||||||
|
uid: 'demo-event-8@example.com',
|
||||||
|
title: 'Carlos Rivera\'s Birthday',
|
||||||
|
description: '',
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-30),
|
||||||
|
start: demoISODate(-3),
|
||||||
|
utcStart: demoDate(-3),
|
||||||
|
utcEnd: demoDate(-2),
|
||||||
|
duration: 'P1D',
|
||||||
|
timeZone: null,
|
||||||
|
showWithoutTime: true,
|
||||||
|
freeBusyStatus: 'free' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
export function createDemoAddressBooks(): AddressBook[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-addressbook-personal',
|
||||||
|
name: 'Personal',
|
||||||
|
isDefault: true,
|
||||||
|
isSubscribed: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-addressbook-work',
|
||||||
|
name: 'Work',
|
||||||
|
isDefault: false,
|
||||||
|
isSubscribed: true,
|
||||||
|
sortOrder: 2,
|
||||||
|
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDemoContacts(): ContactCard[] {
|
||||||
|
return [
|
||||||
|
// ── Personal address book ──────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-contact-1',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Johnson' }] },
|
||||||
|
emails: { e1: { address: 'alice.johnson@example.com', contexts: { work: true }, pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0101', features: { voice: true }, contexts: { work: true } } },
|
||||||
|
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
|
||||||
|
titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
|
||||||
|
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-2',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Chen' }] },
|
||||||
|
emails: {
|
||||||
|
e1: { address: 'bob.chen@example.com', contexts: { work: true }, pref: 1 },
|
||||||
|
e2: { address: 'bob.personal@email.example', contexts: { private: true } },
|
||||||
|
},
|
||||||
|
phones: {
|
||||||
|
p1: { number: '+1-555-0102', features: { voice: true }, contexts: { work: true } },
|
||||||
|
p2: { number: '+1-555-0103', features: { cell: true }, contexts: { private: true } },
|
||||||
|
},
|
||||||
|
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
|
||||||
|
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-3',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Sarah' }, { kind: 'surname', value: 'Kim' }] },
|
||||||
|
emails: { e1: { address: 'sarah.kim@example.com', pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
|
||||||
|
organizations: { o1: { name: 'DesignCo' } },
|
||||||
|
titles: { t1: { name: 'UX Designer', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-4',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Carlos' }, { kind: 'surname', value: 'Rivera' }] },
|
||||||
|
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
|
||||||
|
notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-5',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Emma' }, { kind: 'surname', value: 'Wilson' }] },
|
||||||
|
emails: { e1: { address: 'emma.wilson@example.com', pref: 1 } },
|
||||||
|
addresses: {
|
||||||
|
a1: {
|
||||||
|
components: [
|
||||||
|
{ kind: 'number', value: '456' },
|
||||||
|
{ kind: 'name', value: 'Elm Street' },
|
||||||
|
{ kind: 'locality', value: 'Springfield' },
|
||||||
|
{ kind: 'region', value: 'IL' },
|
||||||
|
{ kind: 'postcode', value: '62701' },
|
||||||
|
],
|
||||||
|
contexts: { private: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-6',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
|
||||||
|
emails: { e1: { address: 'david.park@example.com', pref: 1 } },
|
||||||
|
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-7',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'org',
|
||||||
|
name: { components: [{ kind: 'surname', value: 'Local Coffee Shop' }] },
|
||||||
|
emails: { e1: { address: 'hello@localcoffee.example', pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0200', features: { voice: true } } },
|
||||||
|
addresses: {
|
||||||
|
a1: {
|
||||||
|
components: [
|
||||||
|
{ kind: 'number', value: '789' },
|
||||||
|
{ kind: 'name', value: 'Main Street' },
|
||||||
|
{ kind: 'locality', value: 'Anytown' },
|
||||||
|
{ kind: 'region', value: 'CA' },
|
||||||
|
{ kind: 'postcode', value: '90210' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-8',
|
||||||
|
addressBookIds: { 'demo-addressbook-personal': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
|
||||||
|
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Work address book ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-contact-9',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Michael' }, { kind: 'surname', value: 'Torres' }] },
|
||||||
|
emails: { e1: { address: 'michael.torres@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
|
||||||
|
titles: { t1: { name: 'Product Manager', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-10',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Rachel' }, { kind: 'surname', value: 'Green' }] },
|
||||||
|
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
|
||||||
|
titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-11',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'James' }, { kind: 'surname', value: 'Miller' }] },
|
||||||
|
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
|
||||||
|
titles: { t1: { name: 'CTO', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-12',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Priya' }, { kind: 'surname', value: 'Sharma' }] },
|
||||||
|
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
|
||||||
|
titles: { t1: { name: 'QA Engineer', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-13',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Ahmed' }, { kind: 'surname', value: 'Hassan' }] },
|
||||||
|
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
|
||||||
|
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-14',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Maria' }, { kind: 'surname', value: 'Lopez' }] },
|
||||||
|
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
|
||||||
|
titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-contact-15',
|
||||||
|
addressBookIds: { 'demo-addressbook-work': true },
|
||||||
|
kind: 'individual',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Wei' }, { kind: 'surname', value: 'Zhang' }] },
|
||||||
|
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
|
||||||
|
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
|
||||||
|
titles: { t1: { name: 'Data Scientist', kind: 'title' } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import type { Email } from '@/lib/jmap/types';
|
||||||
|
import { demoDate } from '../demo-utils';
|
||||||
|
|
||||||
|
export function createDemoEmails(): Email[] {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ── Inbox ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-1',
|
||||||
|
threadId: 'demo-thread-1',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 4200,
|
||||||
|
receivedAt: demoDate(0, -2),
|
||||||
|
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Welcome to Bulwark Mail!',
|
||||||
|
sentAt: demoDate(0, -2),
|
||||||
|
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-1', size: 350, type: 'text/plain' }],
|
||||||
|
htmlBody: [{ partId: '2', blobId: 'blob-2', size: 800, type: 'text/html' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!' },
|
||||||
|
'2': { value: '<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>' },
|
||||||
|
},
|
||||||
|
messageId: '<welcome@demo.bulwark.email>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-2',
|
||||||
|
threadId: 'demo-thread-2',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 18500,
|
||||||
|
receivedAt: demoDate(-1, -5),
|
||||||
|
from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'This Week in Tech: AI Developments & Open Source Updates',
|
||||||
|
sentAt: demoDate(-1, -5),
|
||||||
|
preview: 'Your weekly roundup of the most important technology news and open source developments...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-3', size: 2400, type: 'text/plain' }],
|
||||||
|
htmlBody: [{ partId: '2', blobId: 'blob-4', size: 5200, type: 'text/html' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'This Week in Tech\n\n1. AI-Powered Code Review Tools\nNew tools are making code reviews faster and more thorough...\n\n2. Open Source Licensing Update\nThe OSI has published new guidelines for AI-generated code...\n\n3. WebAssembly 2.0 Draft\nThe W3C has released the first draft of WebAssembly 2.0...\n\nRead more at techdigest.example' },
|
||||||
|
'2': { value: '<div style="max-width:600px;margin:0 auto;"><h1>This Week in Tech</h1><h3>1. AI-Powered Code Review Tools</h3><p>New tools are making code reviews faster and more thorough, with several open-source options gaining traction.</p><h3>2. Open Source Licensing Update</h3><p>The OSI has published new guidelines for AI-generated code contributions to open source projects.</p><h3>3. WebAssembly 2.0 Draft</h3><p>The W3C has released the first draft of WebAssembly 2.0, promising improved memory management.</p></div>' },
|
||||||
|
},
|
||||||
|
messageId: '<weekly-42@techdigest.example>',
|
||||||
|
},
|
||||||
|
// Thread: Project discussion (3 emails in same thread)
|
||||||
|
{
|
||||||
|
id: 'demo-email-3a',
|
||||||
|
threadId: 'demo-thread-3',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 3100,
|
||||||
|
receivedAt: demoDate(-3, -10),
|
||||||
|
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||||
|
subject: 'Q4 Project Timeline',
|
||||||
|
sentAt: demoDate(-3, -10),
|
||||||
|
preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
|
||||||
|
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review — Oct 15\n- Phase 2: Development — Nov 1-30\n- Phase 3: Testing — Dec 1-15\n- Phase 4: Launch — Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
|
||||||
|
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review — Oct 15</li><li>Phase 2: Development — Nov 1-30</li><li>Phase 3: Testing — Dec 1-15</li><li>Phase 4: Launch — Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
|
||||||
|
},
|
||||||
|
messageId: '<q4-timeline-1@example.com>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-3b',
|
||||||
|
threadId: 'demo-thread-3',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 3500,
|
||||||
|
receivedAt: demoDate(-2, -8),
|
||||||
|
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||||
|
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Re: Q4 Project Timeline',
|
||||||
|
sentAt: demoDate(-2, -8),
|
||||||
|
preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n— Bob' },
|
||||||
|
},
|
||||||
|
messageId: '<q4-timeline-2@example.com>',
|
||||||
|
inReplyTo: ['<q4-timeline-1@example.com>'],
|
||||||
|
references: ['<q4-timeline-1@example.com>'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-3c',
|
||||||
|
threadId: 'demo-thread-3',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 3800,
|
||||||
|
receivedAt: demoDate(-1, -3),
|
||||||
|
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||||
|
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Re: Q4 Project Timeline',
|
||||||
|
sentAt: demoDate(-1, -3),
|
||||||
|
preview: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n— Alice' },
|
||||||
|
},
|
||||||
|
messageId: '<q4-timeline-3@example.com>',
|
||||||
|
inReplyTo: ['<q4-timeline-2@example.com>'],
|
||||||
|
references: ['<q4-timeline-1@example.com>', '<q4-timeline-2@example.com>'],
|
||||||
|
},
|
||||||
|
// Email with attachments
|
||||||
|
{
|
||||||
|
id: 'demo-email-4',
|
||||||
|
threadId: 'demo-thread-4',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 245000,
|
||||||
|
receivedAt: demoDate(0, -6),
|
||||||
|
from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Invoice #2024-089 & Project Screenshot',
|
||||||
|
sentAt: demoDate(0, -6),
|
||||||
|
preview: 'Hi, please find attached the invoice for October and a screenshot of the latest prototype...',
|
||||||
|
hasAttachment: true,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-9', size: 280, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype.\n\nLet me know if you have any questions.\n\nBest regards,\nSarah' },
|
||||||
|
},
|
||||||
|
attachments: [
|
||||||
|
{ partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' },
|
||||||
|
{ partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' },
|
||||||
|
],
|
||||||
|
messageId: '<invoice-089@example.com>',
|
||||||
|
},
|
||||||
|
// Starred email
|
||||||
|
{
|
||||||
|
id: 'demo-email-5',
|
||||||
|
threadId: 'demo-thread-5',
|
||||||
|
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||||
|
keywords: { $seen: true, $flagged: true },
|
||||||
|
size: 2800,
|
||||||
|
receivedAt: demoDate(-2, -1),
|
||||||
|
from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Reminder: Team Dinner Friday',
|
||||||
|
sentAt: demoDate(-2, -1),
|
||||||
|
preview: 'Hey! Just a reminder about our team dinner this Friday at 7 PM at The Garden Bistro...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-10', size: 320, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hey!\n\nJust a reminder about our team dinner this Friday at 7 PM at The Garden Bistro. I\'ve made a reservation for 8 people.\n\nAddress: 123 Oak Street\n\nLet me know if you can make it!\n\nCheers,\nCarlos' },
|
||||||
|
},
|
||||||
|
messageId: '<dinner-reminder@example.com>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Sent ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-6',
|
||||||
|
threadId: 'demo-thread-6',
|
||||||
|
mailboxIds: { 'demo-mailbox-sent': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 2100,
|
||||||
|
receivedAt: demoDate(-1, -4),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||||
|
subject: 'Updated Requirements Document',
|
||||||
|
sentAt: demoDate(-1, -4),
|
||||||
|
preview: 'Hi Alice, I\'ve updated the requirements document with the changes we discussed...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-11', size: 290, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hi Alice,\n\nI\'ve updated the requirements document with the changes we discussed in yesterday\'s meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User' },
|
||||||
|
},
|
||||||
|
messageId: '<sent-1@example.com>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-7',
|
||||||
|
threadId: 'demo-thread-7',
|
||||||
|
mailboxIds: { 'demo-mailbox-sent': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 1800,
|
||||||
|
receivedAt: demoDate(-4, -2),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
|
||||||
|
subject: 'Re: Design Feedback',
|
||||||
|
sentAt: demoDate(-4, -2),
|
||||||
|
preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-12', size: 250, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet\'s go with Option B for the navigation.\n\nBest,\nDemo User' },
|
||||||
|
},
|
||||||
|
messageId: '<sent-2@example.com>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Drafts ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-8',
|
||||||
|
threadId: 'demo-thread-8',
|
||||||
|
mailboxIds: { 'demo-mailbox-drafts': true },
|
||||||
|
keywords: { $seen: true, $draft: true },
|
||||||
|
size: 900,
|
||||||
|
receivedAt: demoDate(0, -1),
|
||||||
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||||
|
subject: 'Meeting Notes - Draft',
|
||||||
|
sentAt: demoDate(0, -1),
|
||||||
|
preview: 'Here are the notes from today\'s standup...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-13', size: 180, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Here are the notes from today\'s standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ' },
|
||||||
|
},
|
||||||
|
messageId: '<draft-1@example.com>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Trash ───────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-9',
|
||||||
|
threadId: 'demo-thread-9',
|
||||||
|
mailboxIds: { 'demo-mailbox-trash': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 15200,
|
||||||
|
receivedAt: demoDate(-5, -3),
|
||||||
|
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: '🎉 Flash Sale: 50% Off Everything!',
|
||||||
|
sentAt: demoDate(-5, -3),
|
||||||
|
preview: 'Limited time offer! Get 50% off all items in our store...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-14', size: 400, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.' },
|
||||||
|
},
|
||||||
|
messageId: '<promo-1@promostore.example>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-10',
|
||||||
|
threadId: 'demo-thread-10',
|
||||||
|
mailboxIds: { 'demo-mailbox-trash': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 2300,
|
||||||
|
receivedAt: demoDate(-7, 0),
|
||||||
|
from: [{ name: 'System Notification', email: 'noreply@service.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Your password was changed',
|
||||||
|
sentAt: demoDate(-7, 0),
|
||||||
|
preview: 'Your account password was successfully changed on...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-15', size: 200, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Your account password was successfully changed. If you did not make this change, please contact support immediately.' },
|
||||||
|
},
|
||||||
|
messageId: '<notification-1@service.example>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Projects ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-11',
|
||||||
|
threadId: 'demo-thread-11',
|
||||||
|
mailboxIds: { 'demo-mailbox-projects': true },
|
||||||
|
keywords: { $seen: true, $flagged: true },
|
||||||
|
size: 4500,
|
||||||
|
receivedAt: demoDate(-2, -7),
|
||||||
|
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: '[Project] Sprint Planning Agenda',
|
||||||
|
sentAt: demoDate(-2, -7),
|
||||||
|
preview: 'Here\'s the agenda for next week\'s sprint planning session...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-16', size: 600, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hi team,\n\nHere\'s the agenda for next week\'s sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice' },
|
||||||
|
},
|
||||||
|
messageId: '<project-1@example.com>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-email-12',
|
||||||
|
threadId: 'demo-thread-12',
|
||||||
|
mailboxIds: { 'demo-mailbox-projects': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 3200,
|
||||||
|
receivedAt: demoDate(0, -8),
|
||||||
|
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: '[Project] API Rate Limiting Discussion',
|
||||||
|
sentAt: demoDate(0, -8),
|
||||||
|
preview: 'I\'ve been thinking about our rate limiting approach and wanted to propose a few changes...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n— Bob' },
|
||||||
|
},
|
||||||
|
messageId: '<project-2@example.com>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Archive ─────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-13',
|
||||||
|
threadId: 'demo-thread-13',
|
||||||
|
mailboxIds: { 'demo-mailbox-archive': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 2600,
|
||||||
|
receivedAt: demoDate(-14, -6),
|
||||||
|
from: [{ name: 'HR Department', email: 'hr@company.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Updated PTO Policy - Effective January 1',
|
||||||
|
sentAt: demoDate(-14, -6),
|
||||||
|
preview: 'Please review the updated PTO policy that takes effect January 1st...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-18', size: 380, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nHR Department' },
|
||||||
|
},
|
||||||
|
messageId: '<hr-policy-1@company.example>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Receipts ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-14',
|
||||||
|
threadId: 'demo-thread-14',
|
||||||
|
mailboxIds: { 'demo-mailbox-receipts': true },
|
||||||
|
keywords: { $seen: true },
|
||||||
|
size: 5200,
|
||||||
|
receivedAt: demoDate(-3, -12),
|
||||||
|
from: [{ name: 'Cloud Services', email: 'billing@cloudprovider.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Payment Receipt - Invoice #INV-2024-1042',
|
||||||
|
sentAt: demoDate(-3, -12),
|
||||||
|
preview: 'Your payment of $49.99 has been processed successfully...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-19', size: 350, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Payment Confirmation\n\nAmount: $49.99\nDate: Processing date\nInvoice: INV-2024-1042\nService: Cloud Hosting (Standard Plan)\n\nThank you for your payment.' },
|
||||||
|
},
|
||||||
|
messageId: '<receipt-1@cloudprovider.example>',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Spam ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'demo-email-15',
|
||||||
|
threadId: 'demo-thread-15',
|
||||||
|
mailboxIds: { 'demo-mailbox-junk': true },
|
||||||
|
keywords: {},
|
||||||
|
size: 8900,
|
||||||
|
receivedAt: demoDate(-1, -9),
|
||||||
|
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }],
|
||||||
|
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||||
|
subject: 'Congratulations! You Won $1,000,000!!!',
|
||||||
|
sentAt: demoDate(-1, -9),
|
||||||
|
preview: 'Dear lucky winner, you have been selected to receive one million dollars...',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [{ partId: '1', blobId: 'blob-20', size: 500, type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
'1': { value: 'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]' },
|
||||||
|
},
|
||||||
|
messageId: '<spam-1@totallylegit.example>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { FileNode } from '@/lib/jmap/types';
|
||||||
|
import { demoDate } from '../demo-utils';
|
||||||
|
|
||||||
|
export function createDemoFileNodes(): FileNode[] {
|
||||||
|
return [
|
||||||
|
// Root-level directories
|
||||||
|
{
|
||||||
|
id: 'demo-file-documents',
|
||||||
|
parentId: null,
|
||||||
|
name: 'Documents',
|
||||||
|
type: 'd',
|
||||||
|
blobId: null,
|
||||||
|
size: 0,
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-file-photos',
|
||||||
|
parentId: null,
|
||||||
|
name: 'Photos',
|
||||||
|
type: 'd',
|
||||||
|
blobId: null,
|
||||||
|
size: 0,
|
||||||
|
created: demoDate(-30),
|
||||||
|
updated: demoDate(-5),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Documents contents
|
||||||
|
{
|
||||||
|
id: 'demo-file-meeting-notes',
|
||||||
|
parentId: 'demo-file-documents',
|
||||||
|
name: 'meeting-notes.md',
|
||||||
|
type: 'text/markdown',
|
||||||
|
blobId: 'demo-blob-file-1',
|
||||||
|
size: 2150,
|
||||||
|
created: demoDate(-7),
|
||||||
|
updated: demoDate(-2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-file-quarterly-report',
|
||||||
|
parentId: 'demo-file-documents',
|
||||||
|
name: 'quarterly-report.pdf',
|
||||||
|
type: 'application/pdf',
|
||||||
|
blobId: 'demo-blob-file-2',
|
||||||
|
size: 148480,
|
||||||
|
created: demoDate(-14),
|
||||||
|
updated: demoDate(-14),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-file-todo',
|
||||||
|
parentId: 'demo-file-documents',
|
||||||
|
name: 'todo.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
blobId: 'demo-blob-file-3',
|
||||||
|
size: 410,
|
||||||
|
created: demoDate(-3),
|
||||||
|
updated: demoDate(-1),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Photos contents
|
||||||
|
{
|
||||||
|
id: 'demo-file-vacation',
|
||||||
|
parentId: 'demo-file-photos',
|
||||||
|
name: 'vacation.jpg',
|
||||||
|
type: 'image/jpeg',
|
||||||
|
blobId: 'demo-blob-file-4',
|
||||||
|
size: 1258291,
|
||||||
|
created: demoDate(-10),
|
||||||
|
updated: demoDate(-10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-file-team-photo',
|
||||||
|
parentId: 'demo-file-photos',
|
||||||
|
name: 'team-photo.png',
|
||||||
|
type: 'image/png',
|
||||||
|
blobId: 'demo-blob-file-5',
|
||||||
|
size: 911360,
|
||||||
|
created: demoDate(-21),
|
||||||
|
updated: demoDate(-21),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Root-level file
|
||||||
|
{
|
||||||
|
id: 'demo-file-budget',
|
||||||
|
parentId: null,
|
||||||
|
name: 'budget.xlsx',
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
blobId: 'demo-blob-file-6',
|
||||||
|
size: 68608,
|
||||||
|
created: demoDate(-5),
|
||||||
|
updated: demoDate(-1),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
|
|
||||||
|
export function createDemoSieveCapabilities(): SieveCapabilities {
|
||||||
|
return {
|
||||||
|
implementation: 'Demo Sieve Engine',
|
||||||
|
maxSizeScript: 65536,
|
||||||
|
sieveExtensions: ['fileinto', 'reject', 'vacation', 'imap4flags', 'comparator-i;ascii-casemap', 'body', 'envelope'],
|
||||||
|
notificationMethods: [],
|
||||||
|
externalLists: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDemoSieveScripts(): SieveScript[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-sieve-1',
|
||||||
|
name: 'Default Filters',
|
||||||
|
blobId: 'demo-sieve-blob-1',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sieve script content keyed by blobId
|
||||||
|
export function createDemoSieveContent(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
'demo-sieve-blob-1': [
|
||||||
|
'require ["fileinto", "imap4flags"];',
|
||||||
|
'',
|
||||||
|
'# Newsletters to Receipts',
|
||||||
|
'if address :contains "from" "newsletter@" {',
|
||||||
|
' fileinto "Receipts";',
|
||||||
|
' stop;',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'# Flag emails from boss',
|
||||||
|
'if address :is "from" "alice.johnson@example.com" {',
|
||||||
|
' addflag "\\\\Flagged";',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'# Move project updates',
|
||||||
|
'if header :contains "subject" "[Project]" {',
|
||||||
|
' fileinto "Projects";',
|
||||||
|
' stop;',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
export function createDemoIdentities(): Identity[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'demo-identity-primary',
|
||||||
|
name: 'Demo User',
|
||||||
|
email: 'demo@example.com',
|
||||||
|
textSignature: 'Best regards,\nDemo User\nBulwark Mail Demo',
|
||||||
|
htmlSignature: '<p>Best regards,<br><b>Demo User</b><br>Bulwark Mail Demo</p>',
|
||||||
|
mayDelete: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-identity-alias',
|
||||||
|
name: 'Demo User',
|
||||||
|
email: 'demo+newsletter@example.com',
|
||||||
|
textSignature: '',
|
||||||
|
htmlSignature: '',
|
||||||
|
mayDelete: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Mailbox } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
const RIGHTS_SYSTEM = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: false, mayDelete: false, maySubmit: true };
|
||||||
|
const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
|
||||||
|
|
||||||
|
export function createDemoMailboxes(): Mailbox[] {
|
||||||
|
return [
|
||||||
|
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 12, unreadEmails: 5, totalThreads: 10, unreadThreads: 4, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 8, unreadEmails: 0, totalThreads: 8, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 4, unreadEmails: 0, totalThreads: 4, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 1, totalThreads: 3, unreadThreads: 1, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 5, unreadEmails: 2, totalThreads: 5, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||||
|
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { VacationResponse } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
export function createDemoVacationResponse(): VacationResponse {
|
||||||
|
return {
|
||||||
|
id: 'singleton',
|
||||||
|
isEnabled: false,
|
||||||
|
fromDate: null,
|
||||||
|
toDate: null,
|
||||||
|
subject: 'Out of Office',
|
||||||
|
textBody: 'Thank you for your email. I am currently out of the office and will return on Monday. For urgent matters, please contact support@example.com.',
|
||||||
|
htmlBody: '<p>Thank you for your email. I am currently out of the office and will return on Monday.</p><p>For urgent matters, please contact <a href="mailto:support@example.com">support@example.com</a>.</p>',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from "./types";
|
||||||
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining the public JMAP client contract.
|
||||||
|
*
|
||||||
|
* Both the real `JMAPClient` (network-backed) and `DemoJMAPClient`
|
||||||
|
* (in-memory/browser-only) implement this interface so that stores
|
||||||
|
* and UI code never need to know which one is active.
|
||||||
|
*/
|
||||||
|
export interface IJMAPClient {
|
||||||
|
// ── Connection lifecycle ──────────────────────────────────────
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): void;
|
||||||
|
reconnect(): Promise<void>;
|
||||||
|
ping(): Promise<void>;
|
||||||
|
|
||||||
|
// ── Session / auth accessors ──────────────────────────────────
|
||||||
|
getServerUrl(): string;
|
||||||
|
getAuthHeader(): string;
|
||||||
|
updateAccessToken(token: string): void;
|
||||||
|
getAccountId(): string;
|
||||||
|
getUsername(): string;
|
||||||
|
|
||||||
|
// ── Capabilities ──────────────────────────────────────────────
|
||||||
|
getCapabilities(): Record<string, unknown>;
|
||||||
|
getMaxSizeUpload(): number;
|
||||||
|
getMaxCallsInRequest(): number;
|
||||||
|
getMaxObjectsInGet(): number;
|
||||||
|
getEventSourceUrl(): string | null;
|
||||||
|
supportsEmailSubmission(): boolean;
|
||||||
|
supportsQuota(): boolean;
|
||||||
|
supportsVacationResponse(): boolean;
|
||||||
|
supportsContacts(): boolean;
|
||||||
|
supportsCalendars(): boolean;
|
||||||
|
supportsSieve(): boolean;
|
||||||
|
supportsFiles(): boolean;
|
||||||
|
|
||||||
|
// ── Push / state ──────────────────────────────────────────────
|
||||||
|
setupPushNotifications(): boolean;
|
||||||
|
closePushNotifications(): void;
|
||||||
|
onConnectionChange(callback: (connected: boolean) => void): void;
|
||||||
|
onStateChange(callback: (change: StateChange) => void): void;
|
||||||
|
getLastStates(): AccountStates;
|
||||||
|
setLastStates(states: AccountStates): void;
|
||||||
|
|
||||||
|
// ── Quota ─────────────────────────────────────────────────────
|
||||||
|
getQuota(): Promise<{ used: number; total: number } | null>;
|
||||||
|
|
||||||
|
// ── Mailboxes ─────────────────────────────────────────────────
|
||||||
|
getMailboxes(): Promise<Mailbox[]>;
|
||||||
|
getAllMailboxes(): Promise<Mailbox[]>;
|
||||||
|
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
|
||||||
|
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
|
||||||
|
deleteMailbox(mailboxId: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Emails ────────────────────────────────────────────────────
|
||||||
|
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||||
|
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
||||||
|
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
||||||
|
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
||||||
|
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||||
|
advancedSearchEmails(
|
||||||
|
filter: Record<string, unknown>,
|
||||||
|
accountId?: string,
|
||||||
|
limit?: number,
|
||||||
|
position?: number,
|
||||||
|
): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||||
|
|
||||||
|
// ── Email mutations ───────────────────────────────────────────
|
||||||
|
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
|
||||||
|
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
|
||||||
|
toggleStar(emailId: string, starred: boolean): Promise<void>;
|
||||||
|
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
||||||
|
deleteEmail(emailId: string): Promise<void>;
|
||||||
|
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||||
|
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||||
|
batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void>;
|
||||||
|
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||||
|
emptyMailbox(mailboxId: string): Promise<number>;
|
||||||
|
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||||
|
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Threads ───────────────────────────────────────────────────
|
||||||
|
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
|
||||||
|
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
|
||||||
|
|
||||||
|
// ── Compose / Send ────────────────────────────────────────────
|
||||||
|
createDraft(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
identityId?: string,
|
||||||
|
fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||||
|
fromName?: string,
|
||||||
|
): Promise<string>;
|
||||||
|
|
||||||
|
sendEmail(
|
||||||
|
to: string[],
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
cc?: string[],
|
||||||
|
bcc?: string[],
|
||||||
|
identityId?: string,
|
||||||
|
fromEmail?: string,
|
||||||
|
draftId?: string,
|
||||||
|
fromName?: string,
|
||||||
|
htmlBody?: string,
|
||||||
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
sendImipReply(opts: {
|
||||||
|
organizerEmail: string;
|
||||||
|
organizerName?: string;
|
||||||
|
attendeeEmail: string;
|
||||||
|
attendeeName?: string;
|
||||||
|
uid: string;
|
||||||
|
summary?: string;
|
||||||
|
dtStart?: string;
|
||||||
|
dtEnd?: string;
|
||||||
|
timeZone?: string;
|
||||||
|
isAllDay?: boolean;
|
||||||
|
sequence?: number;
|
||||||
|
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
||||||
|
identityId?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
sendImipInvitation(event: CalendarEvent): Promise<void>;
|
||||||
|
sendImipCancellation(event: CalendarEvent): Promise<void>;
|
||||||
|
|
||||||
|
// ── Blobs ─────────────────────────────────────────────────────
|
||||||
|
uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>;
|
||||||
|
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
|
||||||
|
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
|
||||||
|
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
||||||
|
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
||||||
|
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Identities ────────────────────────────────────────────────
|
||||||
|
getIdentities(): Promise<Identity[]>;
|
||||||
|
createIdentity(
|
||||||
|
name: string,
|
||||||
|
email: string,
|
||||||
|
replyTo?: EmailAddress[] | null,
|
||||||
|
bcc?: EmailAddress[] | null,
|
||||||
|
htmlSignature?: string,
|
||||||
|
textSignature?: string,
|
||||||
|
): Promise<Identity>;
|
||||||
|
updateIdentity(
|
||||||
|
identityId: string,
|
||||||
|
updates: {
|
||||||
|
name?: string;
|
||||||
|
replyTo?: EmailAddress[] | null;
|
||||||
|
bcc?: EmailAddress[] | null;
|
||||||
|
htmlSignature?: string;
|
||||||
|
textSignature?: string;
|
||||||
|
},
|
||||||
|
): Promise<void>;
|
||||||
|
deleteIdentity(identityId: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Vacation ──────────────────────────────────────────────────
|
||||||
|
getVacationResponse(): Promise<VacationResponse>;
|
||||||
|
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
|
||||||
|
|
||||||
|
// ── Contacts ──────────────────────────────────────────────────
|
||||||
|
getContactsAccountId(): string;
|
||||||
|
getAddressBooks(): Promise<AddressBook[]>;
|
||||||
|
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||||
|
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||||
|
getAllContacts(): Promise<ContactCard[]>;
|
||||||
|
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||||
|
createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard>;
|
||||||
|
updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void>;
|
||||||
|
deleteContact(contactId: string, targetAccountId?: string): Promise<void>;
|
||||||
|
searchContacts(query: string): Promise<ContactCard[]>;
|
||||||
|
|
||||||
|
// ── Calendars ─────────────────────────────────────────────────
|
||||||
|
getCalendarsAccountId(): string;
|
||||||
|
getCalendars(): Promise<Calendar[]>;
|
||||||
|
getAllCalendars(): Promise<Calendar[]>;
|
||||||
|
createCalendar(calendar: Partial<Calendar>, targetAccountId?: string): Promise<Calendar>;
|
||||||
|
updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void>;
|
||||||
|
deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void>;
|
||||||
|
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||||
|
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
||||||
|
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
||||||
|
updateCalendarEvent(
|
||||||
|
eventId: string,
|
||||||
|
updates: Partial<CalendarEvent>,
|
||||||
|
sendSchedulingMessages?: boolean,
|
||||||
|
targetAccountId?: string,
|
||||||
|
): Promise<void>;
|
||||||
|
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
||||||
|
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||||
|
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||||
|
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
|
||||||
|
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
|
||||||
|
|
||||||
|
// ── Sieve / Filters ──────────────────────────────────────────
|
||||||
|
getSieveAccountId(): string;
|
||||||
|
getSieveCapabilities(): SieveCapabilities | null;
|
||||||
|
getSieveScripts(): Promise<SieveScript[]>;
|
||||||
|
getSieveScriptContent(blobId: string): Promise<string>;
|
||||||
|
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
|
||||||
|
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
|
||||||
|
deleteSieveScript(scriptId: string): Promise<void>;
|
||||||
|
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
|
||||||
|
|
||||||
|
// ── Files (WebDAV / FileNode) ─────────────────────────────────
|
||||||
|
getFilesAccountId(): string;
|
||||||
|
probeFileNodeSupport(): Promise<boolean>;
|
||||||
|
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
||||||
|
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
||||||
|
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
||||||
|
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
||||||
|
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
|
||||||
|
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||||
|
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||||
|
|
||||||
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
|
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
|
||||||
|
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||||
|
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
||||||
|
}
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
|
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
|
||||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||||
|
import type { IJMAPClient } from "./client-interface";
|
||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
|
|
||||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||||
@@ -99,7 +100,7 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
|
|||||||
return emailCount === limit;
|
return emailCount === limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class JMAPClient {
|
export class JMAPClient implements IJMAPClient {
|
||||||
private serverUrl: string;
|
private serverUrl: string;
|
||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
|
|||||||
+66
-1
@@ -40,6 +40,12 @@
|
|||||||
"website": "Webseite",
|
"website": "Webseite",
|
||||||
"imprint": "Impressum",
|
"imprint": "Impressum",
|
||||||
"privacy_policy": "Datenschutz",
|
"privacy_policy": "Datenschutz",
|
||||||
|
"try_demo": "Demo testen",
|
||||||
|
"demo_description": "Erkunden Sie mit Beispieldaten — kein Konto nötig",
|
||||||
|
"demo_launching": "Demo wird gestartet...",
|
||||||
|
"demo_login_button": "Demo starten",
|
||||||
|
"demo_tagline": "Erleben Sie einen voll ausgestatteten E-Mail-Client. Kein Konto erforderlich.",
|
||||||
|
"demo_no_signup": "Keine Registrierung nötig — erkunden Sie frei mit Beispieldaten",
|
||||||
"oauth_completing": "Anmeldung wird abgeschlossen...",
|
"oauth_completing": "Anmeldung wird abgeschlossen...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authentifizierung fehlgeschlagen",
|
"title": "Authentifizierung fehlgeschlagen",
|
||||||
@@ -104,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Suche löschen",
|
"clear_search": "Suche löschen",
|
||||||
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
||||||
|
"demo_banner": "Demo-Modus",
|
||||||
|
"demo_reset": "Zurücksetzen",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"mail": "E-Mail",
|
"mail": "E-Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -847,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Konto",
|
"title": "Konto",
|
||||||
"description": "Zeigen Sie Ihre Kontoinformationen an",
|
"description": "Zeigen Sie Ihre Kontoinformationen an",
|
||||||
|
"name_label": "Anzeigename",
|
||||||
|
"account_type_label": "Kontotyp",
|
||||||
|
"demo_account": "Demokonto",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "E-Mail-Adresse",
|
"label": "E-Mail-Adresse",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -2001,7 +2013,17 @@
|
|||||||
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
|
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
|
||||||
"got_it": "Verstanden",
|
"got_it": "Verstanden",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"dismiss": "Schließen"
|
"dismiss": "Schließen",
|
||||||
|
"start_tour": "Tour starten"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Willkommen bei Bulwark Mail",
|
||||||
|
"description": "Entdecken Sie einen voll ausgestatteten Webmail-Client — direkt in Ihrem Browser. Alle Daten bleiben auf Ihrem Gerät, also testen Sie alles.",
|
||||||
|
"feature_email": "E-Mails lesen & verfassen",
|
||||||
|
"feature_organize": "Tags, Sterne & Ordner",
|
||||||
|
"feature_shortcuts": "Tastenkürzel",
|
||||||
|
"feature_privacy": "100 % private Demo",
|
||||||
|
"hint": "Klicken Sie links auf eine E-Mail, um loszulegen, oder starten Sie die Tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Dateien",
|
"title": "Dateien",
|
||||||
@@ -2181,5 +2203,48 @@
|
|||||||
"export_passphrase_desc": "Wählen Sie eine Passphrase zum Schutz der exportierten PKCS#12-Datei",
|
"export_passphrase_desc": "Wählen Sie eine Passphrase zum Schutz der exportierten PKCS#12-Datei",
|
||||||
"export_storage_desc": "Geben Sie die Speicher-Passphrase ein, um den Schlüssel für den Export zu entschlüsseln",
|
"export_storage_desc": "Geben Sie die Speicher-Passphrase ein, um den Schlüssel für den Export zu entschlüsseln",
|
||||||
"incorrect_passphrase": "Falsche Passphrase"
|
"incorrect_passphrase": "Falsche Passphrase"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Schritt {current} von {total}",
|
||||||
|
"skip": "Tour überspringen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"finish": "Fertig",
|
||||||
|
"take_a_tour": "Machen Sie eine Tour durch die Oberfläche",
|
||||||
|
"restart_title": "Einführungstour",
|
||||||
|
"restart_desc": "Geführte Tour durch die Oberfläche erneut abspielen",
|
||||||
|
"restart_button": "Tour neu starten",
|
||||||
|
"sidebar_title": "Ihre Postfächer",
|
||||||
|
"sidebar_desc": "Dies ist Ihre Ordner-Seitenleiste. Klicken Sie auf ein Postfach, um seine E-Mails anzuzeigen. Sie können Ordner erstellen, E-Mails zwischen ihnen verschieben und ungelesene Zähler auf einen Blick sehen.",
|
||||||
|
"compose_title": "E-Mail verfassen",
|
||||||
|
"compose_desc": "Klicken Sie hier, um eine neue E-Mail zu schreiben. Sie können Empfänger, Anhänge und Textformatierung hinzufügen.",
|
||||||
|
"search_title": "E-Mails durchsuchen",
|
||||||
|
"search_desc": "Suchen Sie nach Absender, Betreff oder Inhalt. Klicken Sie auf das Filtersymbol für erweiterte Optionen wie Datumsbereich, Anhänge und markierte Nachrichten.",
|
||||||
|
"email_list_title": "Ihre E-Mail-Liste",
|
||||||
|
"email_list_desc": "E-Mails erscheinen hier. Klicken Sie auf eine, um sie rechts zu lesen. Verwenden Sie die Checkbox, um mehrere auszuwählen und sie dann zu verschieben, löschen oder taggen.",
|
||||||
|
"email_viewer_title": "Lesebereich",
|
||||||
|
"email_viewer_desc": "Die ausgewählte E-Mail wird hier geöffnet. Antworten, weiterleiten, archivieren oder löschen Sie mit den Schaltflächen. Sie können auch E-Mails markieren oder Farbtags hinzufügen.",
|
||||||
|
"keywords_title": "Farbtags",
|
||||||
|
"keywords_desc": "Organisieren Sie Ihre E-Mails mit farbcodierten Tags. Ziehen Sie eine E-Mail auf ein Tag oder klicken Sie mit der rechten Maustaste.",
|
||||||
|
"calendar_title": "Kalender",
|
||||||
|
"calendar_desc": "Wechseln Sie zum Kalender, um Ihre Termine zu verwalten. Erstellen Sie Ereignisse, setzen Sie Erinnerungen und wählen Sie verschiedene Ansichten.",
|
||||||
|
"contacts_title": "Kontakte",
|
||||||
|
"contacts_desc": "Hier finden Sie Ihr Adressbuch. Importieren Sie Kontakte, erstellen Sie Gruppen und sehen Sie Details.",
|
||||||
|
"settings_title": "Einstellungen",
|
||||||
|
"settings_desc": "Passen Sie alles an: Design, Dichte, Signaturen, Filter, Tastaturkürzel, Kalender-Standards und mehr.",
|
||||||
|
"shortcuts_title": "Tastaturkürzel",
|
||||||
|
"shortcuts_desc": "Für Power-User. Drücken Sie jederzeit ?, um alle verfügbaren Kürzel anzuzeigen.",
|
||||||
|
"calendar_view_title": "Ihr Kalender",
|
||||||
|
"calendar_view_desc": "Hier ist Ihr Kalender mit Beispielterminen. Wechseln Sie zwischen Tag-, Wochen-, Monats- und Agendaansicht.",
|
||||||
|
"contacts_list_title": "Ihre Kontakte",
|
||||||
|
"contacts_list_desc": "Hier sind Ihre Kontakte. Klicken Sie auf einen Kontakt, um Details zu sehen. Sie können neue Kontakte erstellen oder vCards importieren.",
|
||||||
|
"files_title": "Dateispeicher",
|
||||||
|
"settings_tabs_title": "Einstellungsmenü",
|
||||||
|
"settings_tabs_desc": "Hier finden Sie alle Einstellungskategorien. Passen Sie das Erscheinungsbild an, verwalten Sie Identitäten, richten Sie E-Mail-Filter ein, konfigurieren Sie Ihren Kalender und vieles mehr.",
|
||||||
|
"files_desc": "Ihr Dateibrowser zum Hochladen, Organisieren und Teilen von Dateien.",
|
||||||
|
"demo_banner_title": "Demo-Steuerung",
|
||||||
|
"demo_banner_desc": "Sie sind im Demo-Modus — alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
|
||||||
|
"quota_title": "Speichernutzung",
|
||||||
|
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-1
@@ -40,6 +40,12 @@
|
|||||||
"website": "Website",
|
"website": "Website",
|
||||||
"imprint": "Imprint",
|
"imprint": "Imprint",
|
||||||
"privacy_policy": "Privacy Policy",
|
"privacy_policy": "Privacy Policy",
|
||||||
|
"try_demo": "Try Demo",
|
||||||
|
"demo_description": "Explore with sample data — no account needed",
|
||||||
|
"demo_launching": "Launching demo...",
|
||||||
|
"demo_login_button": "Launch Demo",
|
||||||
|
"demo_tagline": "Experience a full-featured email client. No account required.",
|
||||||
|
"demo_no_signup": "No signup needed — explore freely with sample data",
|
||||||
"oauth_completing": "Completing sign in...",
|
"oauth_completing": "Completing sign in...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authentication Failed",
|
"title": "Authentication Failed",
|
||||||
@@ -104,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Clear search",
|
"clear_search": "Clear search",
|
||||||
"vacation_active": "Vacation responder is active",
|
"vacation_active": "Vacation responder is active",
|
||||||
|
"demo_banner": "Demo Mode",
|
||||||
|
"demo_reset": "Reset",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -847,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
"description": "View your account information",
|
"description": "View your account information",
|
||||||
|
"name_label": "Display Name",
|
||||||
|
"account_type_label": "Account Type",
|
||||||
|
"demo_account": "Demo Account",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Email Address",
|
"label": "Email Address",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -2001,7 +2013,17 @@
|
|||||||
"tip_settings": "Customize your experience in Settings",
|
"tip_settings": "Customize your experience in Settings",
|
||||||
"got_it": "Got it",
|
"got_it": "Got it",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"dismiss": "Dismiss"
|
"dismiss": "Dismiss",
|
||||||
|
"start_tour": "Start Tour"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Welcome to Bulwark Mail",
|
||||||
|
"description": "Explore a fully-featured webmail client — right in your browser. All data stays on your device, so feel free to test everything.",
|
||||||
|
"feature_email": "Read & compose email",
|
||||||
|
"feature_organize": "Tags, stars & folders",
|
||||||
|
"feature_shortcuts": "Keyboard shortcuts",
|
||||||
|
"feature_privacy": "100% private demo",
|
||||||
|
"hint": "Click any email on the left to get started, or take the tour below."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Files",
|
"title": "Files",
|
||||||
@@ -2181,5 +2203,54 @@
|
|||||||
"export_passphrase_desc": "Choose a passphrase to protect the exported PKCS#12 file",
|
"export_passphrase_desc": "Choose a passphrase to protect the exported PKCS#12 file",
|
||||||
"export_storage_desc": "Enter the storage passphrase to decrypt the key for export",
|
"export_storage_desc": "Enter the storage passphrase to decrypt the key for export",
|
||||||
"incorrect_passphrase": "Incorrect passphrase"
|
"incorrect_passphrase": "Incorrect passphrase"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Step {current} of {total}",
|
||||||
|
"skip": "Skip tour",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"finish": "Finish",
|
||||||
|
"take_a_tour": "Take a tour of the interface",
|
||||||
|
"restart_title": "Introductory tour",
|
||||||
|
"restart_desc": "Replay the guided walkthrough of the interface",
|
||||||
|
"restart_button": "Restart tour",
|
||||||
|
"sidebar_title": "Your mailboxes",
|
||||||
|
"sidebar_desc": "This is your folder sidebar. Click any mailbox to view its emails. You can create folders, drag emails between them, and see unread counts at a glance.",
|
||||||
|
"compose_title": "Compose an email",
|
||||||
|
"compose_desc": "Click here to write a new email. You can add recipients, attachments, and use rich text formatting.",
|
||||||
|
"search_title": "Search your mail",
|
||||||
|
"search_desc": "Search by sender, subject, or content. Click the filter icon for advanced options like date range, attachments, and starred messages.",
|
||||||
|
"email_list_title": "Your email list",
|
||||||
|
"email_list_desc": "Emails appear here. Click one to read it on the right. Use the checkbox to select multiple, then bulk-move, delete, or tag them.",
|
||||||
|
"email_viewer_title": "Reading pane",
|
||||||
|
"email_viewer_desc": "The selected email opens here. Reply, forward, archive, or delete with the toolbar buttons. You can also star emails or add color tags.",
|
||||||
|
"keywords_title": "Color tags",
|
||||||
|
"keywords_desc": "Organize your email with color-coded tags. Drag an email onto a tag to label it, or right-click an email to assign tags.",
|
||||||
|
"calendar_title": "Calendar",
|
||||||
|
"calendar_desc": "Switch to the calendar to manage your events. Create events, set reminders, and view day, week, or month layouts.",
|
||||||
|
"contacts_title": "Contacts",
|
||||||
|
"contacts_desc": "Your address book lives here. Import contacts, create groups, and click any contact to see their full details.",
|
||||||
|
"settings_title": "Settings",
|
||||||
|
"settings_desc": "Customize everything: theme, density, signatures, filters, keyboard shortcuts, calendar defaults, and more.",
|
||||||
|
"shortcuts_title": "Keyboard shortcuts",
|
||||||
|
"shortcuts_desc": "Power users love this. Press ? anytime to see all available shortcuts. You can navigate, compose, and manage emails without touching a mouse.",
|
||||||
|
"compose_open_title": "The composer",
|
||||||
|
"compose_open_desc": "This is the email composer. Add recipients, write your message, attach files, and use rich text formatting. You can also save drafts and use templates.",
|
||||||
|
"calendar_view_title": "Your calendar",
|
||||||
|
"calendar_view_desc": "Here's your calendar with sample events. You can switch between day, week, month, and agenda views using the toolbar.",
|
||||||
|
"create_event_title": "Create an event",
|
||||||
|
"create_event_desc": "Click this button to create a new calendar event. You can set a title, date, time, and add participants.",
|
||||||
|
"event_modal_title": "Event details",
|
||||||
|
"event_modal_desc": "Here's the event form. Fill in the title, pick a date and time, add a location or participants. Hit save when you're done — or close it and move on.",
|
||||||
|
"contacts_list_title": "Your contacts",
|
||||||
|
"contacts_list_desc": "Here are your contacts. Click any contact to see their full details on the right. You can also create new contacts, import vCards, or organize contacts into groups.",
|
||||||
|
"settings_tabs_title": "Settings menu",
|
||||||
|
"settings_tabs_desc": "Here are all the settings categories. Customize your appearance, manage identities, set up email filters, configure your calendar, and much more.",
|
||||||
|
"files_title": "File storage",
|
||||||
|
"files_desc": "Your file browser lets you upload, organize, and share files — like a personal cloud drive built into your mail.",
|
||||||
|
"demo_banner_title": "Demo controls",
|
||||||
|
"demo_banner_desc": "You're in demo mode — everything stays in your browser. Hit 'Reset Demo' anytime to start fresh with clean sample data.",
|
||||||
|
"quota_title": "Storage usage",
|
||||||
|
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-1
@@ -40,6 +40,12 @@
|
|||||||
"website": "Sitio web",
|
"website": "Sitio web",
|
||||||
"imprint": "Aviso legal",
|
"imprint": "Aviso legal",
|
||||||
"privacy_policy": "Política de privacidad",
|
"privacy_policy": "Política de privacidad",
|
||||||
|
"try_demo": "Probar demo",
|
||||||
|
"demo_description": "Explora con datos de ejemplo — sin cuenta necesaria",
|
||||||
|
"demo_launching": "Iniciando demo...",
|
||||||
|
"demo_login_button": "Iniciar demo",
|
||||||
|
"demo_tagline": "Experimenta un cliente de correo completo. Sin necesidad de cuenta.",
|
||||||
|
"demo_no_signup": "Sin registro — explora libremente con datos de ejemplo",
|
||||||
"oauth_completing": "Completando inicio de sesión...",
|
"oauth_completing": "Completando inicio de sesión...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Error de autenticación",
|
"title": "Error de autenticación",
|
||||||
@@ -104,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Limpiar búsqueda",
|
"clear_search": "Limpiar búsqueda",
|
||||||
"vacation_active": "Respuesta automática activa",
|
"vacation_active": "Respuesta automática activa",
|
||||||
|
"demo_banner": "Modo demo",
|
||||||
|
"demo_reset": "Restablecer",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"mail": "Correo",
|
"mail": "Correo",
|
||||||
"nav_label": "Navegación",
|
"nav_label": "Navegación",
|
||||||
@@ -847,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Cuenta",
|
"title": "Cuenta",
|
||||||
"description": "Vea la información de su cuenta",
|
"description": "Vea la información de su cuenta",
|
||||||
|
"name_label": "Nombre para mostrar",
|
||||||
|
"account_type_label": "Tipo de cuenta",
|
||||||
|
"demo_account": "Cuenta de demostración",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Dirección de Correo",
|
"label": "Dirección de Correo",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -2001,7 +2013,17 @@
|
|||||||
"tip_settings": "Personaliza tu experiencia en Ajustes",
|
"tip_settings": "Personaliza tu experiencia en Ajustes",
|
||||||
"got_it": "Entendido",
|
"got_it": "Entendido",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"dismiss": "Cerrar"
|
"dismiss": "Cerrar",
|
||||||
|
"start_tour": "Iniciar tour"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Bienvenido a Bulwark Mail",
|
||||||
|
"description": "Explora un cliente de correo web completo — directamente en tu navegador. Todos los datos quedan en tu dispositivo, así que prueba todo.",
|
||||||
|
"feature_email": "Leer y redactar correos",
|
||||||
|
"feature_organize": "Etiquetas, estrellas y carpetas",
|
||||||
|
"feature_shortcuts": "Atajos de teclado",
|
||||||
|
"feature_privacy": "Demo 100 % privada",
|
||||||
|
"hint": "Haz clic en un correo a la izquierda para empezar, o inicia el tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Archivos",
|
"title": "Archivos",
|
||||||
@@ -2181,5 +2203,48 @@
|
|||||||
"export_passphrase_desc": "Elige una contraseña para proteger el archivo PKCS#12 exportado",
|
"export_passphrase_desc": "Elige una contraseña para proteger el archivo PKCS#12 exportado",
|
||||||
"export_storage_desc": "Introduce la contraseña de almacenamiento para descifrar la clave antes de exportarla",
|
"export_storage_desc": "Introduce la contraseña de almacenamiento para descifrar la clave antes de exportarla",
|
||||||
"incorrect_passphrase": "Contraseña incorrecta"
|
"incorrect_passphrase": "Contraseña incorrecta"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Paso {current} de {total}",
|
||||||
|
"skip": "Saltar tour",
|
||||||
|
"back": "Atrás",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"finish": "Finalizar",
|
||||||
|
"take_a_tour": "Haz un recorrido por la interfaz",
|
||||||
|
"restart_title": "Tour introductorio",
|
||||||
|
"restart_desc": "Repetir el recorrido guiado por la interfaz",
|
||||||
|
"restart_button": "Reiniciar tour",
|
||||||
|
"sidebar_title": "Tus buzones",
|
||||||
|
"sidebar_desc": "Esta es tu barra lateral de carpetas. Haz clic en cualquier buzón para ver sus correos. Puedes crear carpetas, arrastrar correos entre ellas y ver los contadores de no leídos.",
|
||||||
|
"compose_title": "Redactar un correo",
|
||||||
|
"compose_desc": "Haz clic aquí para escribir un nuevo correo. Puedes añadir destinatarios, archivos adjuntos y formato de texto enriquecido.",
|
||||||
|
"search_title": "Buscar en tu correo",
|
||||||
|
"search_desc": "Busca por remitente, asunto o contenido. Haz clic en el icono de filtro para opciones avanzadas como rango de fechas, adjuntos y mensajes destacados.",
|
||||||
|
"email_list_title": "Tu lista de correos",
|
||||||
|
"email_list_desc": "Los correos aparecen aquí. Haz clic en uno para leerlo a la derecha. Usa la casilla para seleccionar varios y moverlos, eliminarlos o etiquetarlos.",
|
||||||
|
"email_viewer_title": "Panel de lectura",
|
||||||
|
"email_viewer_desc": "El correo seleccionado se abre aquí. Responde, reenvía, archiva o elimina con los botones de la barra. También puedes destacar correos o añadir etiquetas de color.",
|
||||||
|
"keywords_title": "Etiquetas de color",
|
||||||
|
"keywords_desc": "Organiza tu correo con etiquetas de colores. Arrastra un correo sobre una etiqueta o haz clic derecho para asignarlas.",
|
||||||
|
"calendar_title": "Calendario",
|
||||||
|
"calendar_desc": "Cambia al calendario para gestionar tus eventos. Crea eventos, configura recordatorios y elige diferentes vistas.",
|
||||||
|
"contacts_title": "Contactos",
|
||||||
|
"contacts_desc": "Tu libreta de direcciones está aquí. Importa contactos, crea grupos y consulta los detalles.",
|
||||||
|
"settings_title": "Ajustes",
|
||||||
|
"settings_desc": "Personaliza todo: tema, densidad, firmas, filtros, atajos de teclado, valores predeterminados del calendario y más.",
|
||||||
|
"shortcuts_title": "Atajos de teclado",
|
||||||
|
"shortcuts_desc": "Para usuarios avanzados. Pulsa ? en cualquier momento para ver todos los atajos disponibles.",
|
||||||
|
"calendar_view_title": "Tu calendario",
|
||||||
|
"calendar_view_desc": "Aquí está tu calendario con eventos de ejemplo. Cambia entre vistas de día, semana, mes y agenda.",
|
||||||
|
"contacts_list_title": "Tus contactos",
|
||||||
|
"contacts_list_desc": "Aquí están tus contactos. Haz clic en cualquier contacto para ver sus detalles. Puedes crear contactos nuevos o importar vCards.",
|
||||||
|
"files_title": "Almacenamiento de archivos",
|
||||||
|
"settings_tabs_title": "Menú de ajustes",
|
||||||
|
"settings_tabs_desc": "Aquí están todas las categorías de ajustes. Personaliza la apariencia, gestiona identidades, configura filtros de correo, ajusta tu calendario y mucho más.",
|
||||||
|
"files_desc": "Tu explorador de archivos para subir, organizar y compartir archivos.",
|
||||||
|
"demo_banner_title": "Controles de demo",
|
||||||
|
"demo_banner_desc": "Estás en modo demo — todo permanece en tu navegador. Haz clic en 'Restablecer demo' en cualquier momento.",
|
||||||
|
"quota_title": "Uso de almacenamiento",
|
||||||
|
"quota_desc": "Controla el tamaño de tu buzón aquí. El círculo se llena a medida que usas más espacio."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-1
@@ -40,6 +40,12 @@
|
|||||||
"website": "Site web",
|
"website": "Site web",
|
||||||
"imprint": "Mentions légales",
|
"imprint": "Mentions légales",
|
||||||
"privacy_policy": "Politique de confidentialité",
|
"privacy_policy": "Politique de confidentialité",
|
||||||
|
"try_demo": "Essayer la démo",
|
||||||
|
"demo_description": "Explorez avec des données d'exemple — aucun compte nécessaire",
|
||||||
|
"demo_launching": "Lancement de la démo...",
|
||||||
|
"demo_login_button": "Lancer la démo",
|
||||||
|
"demo_tagline": "Découvrez un client de messagerie complet. Aucun compte requis.",
|
||||||
|
"demo_no_signup": "Aucune inscription — explorez librement avec des données d'exemple",
|
||||||
"oauth_completing": "Connexion en cours...",
|
"oauth_completing": "Connexion en cours...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Échec de l'authentification",
|
"title": "Échec de l'authentification",
|
||||||
@@ -104,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Effacer la recherche",
|
"clear_search": "Effacer la recherche",
|
||||||
"vacation_active": "Répondeur d'absence activé",
|
"vacation_active": "Répondeur d'absence activé",
|
||||||
|
"demo_banner": "Mode démo",
|
||||||
|
"demo_reset": "Réinitialiser",
|
||||||
|
"demo_tour": "Visite",
|
||||||
"tags": "Étiquettes",
|
"tags": "Étiquettes",
|
||||||
"mail": "Messagerie",
|
"mail": "Messagerie",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
@@ -847,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Compte",
|
"title": "Compte",
|
||||||
"description": "Consultez les informations de votre compte",
|
"description": "Consultez les informations de votre compte",
|
||||||
|
"name_label": "Nom d'affichage",
|
||||||
|
"account_type_label": "Type de compte",
|
||||||
|
"demo_account": "Compte de démonstration",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Adresse email",
|
"label": "Adresse email",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -2001,7 +2013,17 @@
|
|||||||
"tip_settings": "Personnalisez votre experience dans les Parametres",
|
"tip_settings": "Personnalisez votre experience dans les Parametres",
|
||||||
"got_it": "Compris",
|
"got_it": "Compris",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"dismiss": "Fermer"
|
"dismiss": "Fermer",
|
||||||
|
"start_tour": "Démarrer la visite"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Bienvenue sur Bulwark Mail",
|
||||||
|
"description": "Explorez un client webmail complet — directement dans votre navigateur. Toutes les données restent sur votre appareil, alors testez tout.",
|
||||||
|
"feature_email": "Lire et rédiger des e-mails",
|
||||||
|
"feature_organize": "Tags, étoiles et dossiers",
|
||||||
|
"feature_shortcuts": "Raccourcis clavier",
|
||||||
|
"feature_privacy": "Démo 100 % privée",
|
||||||
|
"hint": "Cliquez sur un e-mail à gauche pour commencer, ou lancez la visite."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Fichiers",
|
"title": "Fichiers",
|
||||||
@@ -2181,5 +2203,48 @@
|
|||||||
"export_passphrase_desc": "Choisissez une phrase secrète pour protéger le fichier PKCS#12 exporté",
|
"export_passphrase_desc": "Choisissez une phrase secrète pour protéger le fichier PKCS#12 exporté",
|
||||||
"export_storage_desc": "Entrez la phrase secrète de stockage pour déchiffrer la clé avant l'export",
|
"export_storage_desc": "Entrez la phrase secrète de stockage pour déchiffrer la clé avant l'export",
|
||||||
"incorrect_passphrase": "Phrase secrète incorrecte"
|
"incorrect_passphrase": "Phrase secrète incorrecte"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Étape {current} sur {total}",
|
||||||
|
"skip": "Passer la visite",
|
||||||
|
"back": "Retour",
|
||||||
|
"next": "Suivant",
|
||||||
|
"finish": "Terminer",
|
||||||
|
"take_a_tour": "Faire une visite de l'interface",
|
||||||
|
"restart_title": "Visite d'introduction",
|
||||||
|
"restart_desc": "Rejouer la visite guidée de l'interface",
|
||||||
|
"restart_button": "Relancer la visite",
|
||||||
|
"sidebar_title": "Vos boîtes mail",
|
||||||
|
"sidebar_desc": "Voici votre barre latérale de dossiers. Cliquez sur une boîte pour voir ses emails. Vous pouvez créer des dossiers, glisser des emails entre eux et voir les compteurs de non lus.",
|
||||||
|
"compose_title": "Rédiger un email",
|
||||||
|
"compose_desc": "Cliquez ici pour écrire un nouvel email. Ajoutez des destinataires, des pièces jointes et utilisez la mise en forme enrichie.",
|
||||||
|
"search_title": "Rechercher vos emails",
|
||||||
|
"search_desc": "Recherchez par expéditeur, objet ou contenu. Cliquez sur l'icône de filtre pour les options avancées comme la plage de dates, les pièces jointes et les messages suivis.",
|
||||||
|
"email_list_title": "Votre liste d'emails",
|
||||||
|
"email_list_desc": "Les emails apparaissent ici. Cliquez sur un email pour le lire à droite. Utilisez la case à cocher pour en sélectionner plusieurs, puis déplacez, supprimez ou étiquetez-les.",
|
||||||
|
"email_viewer_title": "Panneau de lecture",
|
||||||
|
"email_viewer_desc": "L'email sélectionné s'ouvre ici. Répondez, transférez, archivez ou supprimez avec les boutons de la barre d'outils. Vous pouvez aussi marquer les emails ou ajouter des étiquettes de couleur.",
|
||||||
|
"keywords_title": "Étiquettes de couleur",
|
||||||
|
"keywords_desc": "Organisez vos emails avec des étiquettes colorées. Glissez un email sur une étiquette pour le marquer, ou faites un clic droit pour assigner des étiquettes.",
|
||||||
|
"calendar_title": "Calendrier",
|
||||||
|
"calendar_desc": "Accédez au calendrier pour gérer vos événements. Créez des événements, définissez des rappels et consultez les vues jour, semaine ou mois.",
|
||||||
|
"contacts_title": "Contacts",
|
||||||
|
"contacts_desc": "Votre carnet d'adresses se trouve ici. Importez des contacts, créez des groupes et cliquez sur un contact pour voir ses détails.",
|
||||||
|
"settings_title": "Paramètres",
|
||||||
|
"settings_desc": "Personnalisez tout : thème, densité, signatures, filtres, raccourcis clavier, paramètres du calendrier et plus encore.",
|
||||||
|
"shortcuts_title": "Raccourcis clavier",
|
||||||
|
"shortcuts_desc": "Les utilisateurs avancés adorent ça. Appuyez sur ? à tout moment pour voir tous les raccourcis disponibles. Naviguez, rédigez et gérez vos emails sans toucher à la souris.",
|
||||||
|
"calendar_view_title": "Votre calendrier",
|
||||||
|
"calendar_view_desc": "Voici votre calendrier avec des événements exemples. Basculez entre les vues jour, semaine, mois et agenda avec la barre d'outils.",
|
||||||
|
"contacts_list_title": "Vos contacts",
|
||||||
|
"contacts_list_desc": "Voici vos contacts. Cliquez sur un contact pour voir ses détails à droite. Vous pouvez aussi créer de nouveaux contacts, importer des vCards ou organiser les contacts en groupes.",
|
||||||
|
"files_title": "Stockage de fichiers",
|
||||||
|
"settings_tabs_title": "Menu des paramètres",
|
||||||
|
"settings_tabs_desc": "Voici toutes les catégories de paramètres. Personnalisez l'apparence, gérez les identités, configurez les filtres de messagerie, paramétrez votre calendrier et bien plus encore.",
|
||||||
|
"files_desc": "Votre gestionnaire de fichiers vous permet de téléverser, organiser et partager des fichiers — comme un cloud personnel intégré à votre messagerie.",
|
||||||
|
"demo_banner_title": "Contrôles de démo",
|
||||||
|
"demo_banner_desc": "Vous êtes en mode démo — tout reste dans votre navigateur. Cliquez sur 'Réinitialiser la démo' à tout moment pour repartir avec des données fraîches.",
|
||||||
|
"quota_title": "Utilisation du stockage",
|
||||||
|
"quota_desc": "Suivez la taille de votre boîte mail ici. Le cercle se remplit au fur et à mesure que vous utilisez plus d'espace."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-1
@@ -40,6 +40,12 @@
|
|||||||
"website": "Sito web",
|
"website": "Sito web",
|
||||||
"imprint": "Note legali",
|
"imprint": "Note legali",
|
||||||
"privacy_policy": "Informativa sulla privacy",
|
"privacy_policy": "Informativa sulla privacy",
|
||||||
|
"try_demo": "Prova la demo",
|
||||||
|
"demo_description": "Esplora con dati di esempio — nessun account necessario",
|
||||||
|
"demo_launching": "Avvio demo...",
|
||||||
|
"demo_login_button": "Avvia demo",
|
||||||
|
"demo_tagline": "Scopri un client di posta completo. Nessun account richiesto.",
|
||||||
|
"demo_no_signup": "Nessuna registrazione — esplora liberamente con dati di esempio",
|
||||||
"oauth_completing": "Completamento dell'accesso...",
|
"oauth_completing": "Completamento dell'accesso...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Autenticazione non riuscita",
|
"title": "Autenticazione non riuscita",
|
||||||
@@ -104,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Cancella ricerca",
|
"clear_search": "Cancella ricerca",
|
||||||
"vacation_active": "Risponditore automatico attivo",
|
"vacation_active": "Risponditore automatico attivo",
|
||||||
|
"demo_banner": "Modalità demo",
|
||||||
|
"demo_reset": "Reimposta",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Etichette",
|
"tags": "Etichette",
|
||||||
"mail": "Posta",
|
"mail": "Posta",
|
||||||
"nav_label": "Navigazione",
|
"nav_label": "Navigazione",
|
||||||
@@ -847,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
"description": "Visualizza le informazioni del tuo account",
|
"description": "Visualizza le informazioni del tuo account",
|
||||||
|
"name_label": "Nome visualizzato",
|
||||||
|
"account_type_label": "Tipo di account",
|
||||||
|
"demo_account": "Account dimostrativo",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Indirizzo email",
|
"label": "Indirizzo email",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -2001,7 +2013,17 @@
|
|||||||
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
|
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
|
||||||
"got_it": "Ho capito",
|
"got_it": "Ho capito",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
"dismiss": "Chiudi"
|
"dismiss": "Chiudi",
|
||||||
|
"start_tour": "Inizia il tour"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Benvenuto su Bulwark Mail",
|
||||||
|
"description": "Esplora un client webmail completo — direttamente nel tuo browser. Tutti i dati restano sul tuo dispositivo, quindi prova tutto.",
|
||||||
|
"feature_email": "Leggere e scrivere email",
|
||||||
|
"feature_organize": "Tag, stelle e cartelle",
|
||||||
|
"feature_shortcuts": "Scorciatoie da tastiera",
|
||||||
|
"feature_privacy": "Demo 100% privata",
|
||||||
|
"hint": "Clicca su un'email a sinistra per iniziare, oppure fai il tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "File",
|
"title": "File",
|
||||||
@@ -2181,5 +2203,48 @@
|
|||||||
"export_passphrase_desc": "Scegli una passphrase per proteggere il file PKCS#12 esportato",
|
"export_passphrase_desc": "Scegli una passphrase per proteggere il file PKCS#12 esportato",
|
||||||
"export_storage_desc": "Inserisci la passphrase di archiviazione per decrittare la chiave prima dell'esportazione",
|
"export_storage_desc": "Inserisci la passphrase di archiviazione per decrittare la chiave prima dell'esportazione",
|
||||||
"incorrect_passphrase": "Passphrase errata"
|
"incorrect_passphrase": "Passphrase errata"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Passo {current} di {total}",
|
||||||
|
"skip": "Salta il tour",
|
||||||
|
"back": "Indietro",
|
||||||
|
"next": "Avanti",
|
||||||
|
"finish": "Fine",
|
||||||
|
"take_a_tour": "Fai un tour dell'interfaccia",
|
||||||
|
"restart_title": "Tour introduttivo",
|
||||||
|
"restart_desc": "Rivedi la guida dell'interfaccia",
|
||||||
|
"restart_button": "Riavvia il tour",
|
||||||
|
"sidebar_title": "Le tue caselle di posta",
|
||||||
|
"sidebar_desc": "Questa è la barra laterale delle cartelle. Clicca su una casella per vedere le email. Puoi creare cartelle, trascinare email tra loro e vedere i conteggi dei non letti.",
|
||||||
|
"compose_title": "Scrivi un'email",
|
||||||
|
"compose_desc": "Clicca qui per scrivere una nuova email. Puoi aggiungere destinatari, allegati e usare la formattazione RTF.",
|
||||||
|
"search_title": "Cerca nella posta",
|
||||||
|
"search_desc": "Cerca per mittente, oggetto o contenuto. Clicca sull'icona del filtro per opzioni avanzate come intervallo di date, allegati e messaggi speciali.",
|
||||||
|
"email_list_title": "La tua lista email",
|
||||||
|
"email_list_desc": "Le email appaiono qui. Clicca su una per leggerla a destra. Usa la casella di controllo per selezionarne più di una, poi sposta, elimina o etichetta in blocco.",
|
||||||
|
"email_viewer_title": "Pannello di lettura",
|
||||||
|
"email_viewer_desc": "L'email selezionata si apre qui. Rispondi, inoltra, archivia o elimina con i pulsanti della barra degli strumenti. Puoi anche contrassegnare le email o aggiungere etichette colorate.",
|
||||||
|
"keywords_title": "Etichette colorate",
|
||||||
|
"keywords_desc": "Organizza le tue email con etichette colorate. Trascina un'email su un'etichetta per contrassegnarla, o fai clic destro per assegnare etichette.",
|
||||||
|
"calendar_title": "Calendario",
|
||||||
|
"calendar_desc": "Passa al calendario per gestire i tuoi eventi. Crea eventi, imposta promemoria e visualizza le viste giorno, settimana o mese.",
|
||||||
|
"contacts_title": "Contatti",
|
||||||
|
"contacts_desc": "La tua rubrica si trova qui. Importa contatti, crea gruppi e clicca su un contatto per vedere i suoi dettagli completi.",
|
||||||
|
"settings_title": "Impostazioni",
|
||||||
|
"settings_desc": "Personalizza tutto: tema, densità, firme, filtri, scorciatoie da tastiera, impostazioni del calendario e altro ancora.",
|
||||||
|
"shortcuts_title": "Scorciatoie da tastiera",
|
||||||
|
"shortcuts_desc": "Gli utenti esperti adorano questo. Premi ? in qualsiasi momento per vedere tutte le scorciatoie disponibili. Puoi navigare, comporre e gestire le email senza toccare il mouse.",
|
||||||
|
"calendar_view_title": "Il tuo calendario",
|
||||||
|
"calendar_view_desc": "Ecco il tuo calendario con eventi di esempio. Puoi passare tra le viste giorno, settimana, mese e agenda usando la barra degli strumenti.",
|
||||||
|
"contacts_list_title": "I tuoi contatti",
|
||||||
|
"contacts_list_desc": "Ecco i tuoi contatti. Clicca su un contatto per vedere i suoi dettagli a destra. Puoi anche creare nuovi contatti, importare vCard o organizzare i contatti in gruppi.",
|
||||||
|
"files_title": "Archiviazione file",
|
||||||
|
"settings_tabs_title": "Menu impostazioni",
|
||||||
|
"settings_tabs_desc": "Ecco tutte le categorie di impostazioni. Personalizza l'aspetto, gestisci le identità, configura i filtri email, imposta il calendario e molto altro.",
|
||||||
|
"files_desc": "Il tuo file browser ti permette di caricare, organizzare e condividere file — come un cloud personale integrato nella tua posta.",
|
||||||
|
"demo_banner_title": "Controlli demo",
|
||||||
|
"demo_banner_desc": "Sei in modalità demo — tutto rimane nel tuo browser. Premi 'Reimposta Demo' in qualsiasi momento per ricominciare con dati puliti.",
|
||||||
|
"quota_title": "Utilizzo dello spazio",
|
||||||
|
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più spazio."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-1
@@ -40,6 +40,12 @@
|
|||||||
"website": "ウェブサイト",
|
"website": "ウェブサイト",
|
||||||
"imprint": "サイト運営者情報",
|
"imprint": "サイト運営者情報",
|
||||||
"privacy_policy": "プライバシーポリシー",
|
"privacy_policy": "プライバシーポリシー",
|
||||||
|
"try_demo": "デモを試す",
|
||||||
|
"demo_description": "サンプルデータで探索 — アカウント不要",
|
||||||
|
"demo_launching": "デモを起動中...",
|
||||||
|
"demo_login_button": "デモを開始",
|
||||||
|
"demo_tagline": "フル機能のメールクライアントを体験。アカウント不要。",
|
||||||
|
"demo_no_signup": "登録不要 — サンプルデータで自由に探索",
|
||||||
"oauth_completing": "サインイン処理中...",
|
"oauth_completing": "サインイン処理中...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "認証に失敗しました",
|
"title": "認証に失敗しました",
|
||||||
@@ -104,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "検索をクリア",
|
"clear_search": "検索をクリア",
|
||||||
"vacation_active": "不在応答が有効です",
|
"vacation_active": "不在応答が有効です",
|
||||||
|
"demo_banner": "デモモード",
|
||||||
|
"demo_reset": "リセット",
|
||||||
|
"demo_tour": "ツアー",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
"mail": "メール",
|
"mail": "メール",
|
||||||
"nav_label": "ナビゲーション",
|
"nav_label": "ナビゲーション",
|
||||||
@@ -847,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "アカウント",
|
"title": "アカウント",
|
||||||
"description": "アカウント情報を表示",
|
"description": "アカウント情報を表示",
|
||||||
|
"name_label": "表示名",
|
||||||
|
"account_type_label": "アカウントタイプ",
|
||||||
|
"demo_account": "デモアカウント",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "メールアドレス",
|
"label": "メールアドレス",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -2001,7 +2013,17 @@
|
|||||||
"tip_settings": "設定でカスタマイズできます",
|
"tip_settings": "設定でカスタマイズできます",
|
||||||
"got_it": "了解",
|
"got_it": "了解",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"dismiss": "閉じる"
|
"dismiss": "閉じる",
|
||||||
|
"start_tour": "ツアーを開始"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Bulwark Mail へようこそ",
|
||||||
|
"description": "フル機能のウェブメールクライアントをブラウザで体験できます。すべてのデータはお使いのデバイスに保存されるので、自由にお試しください。",
|
||||||
|
"feature_email": "メールの読み書き",
|
||||||
|
"feature_organize": "タグ・スター・フォルダ",
|
||||||
|
"feature_shortcuts": "キーボードショートカット",
|
||||||
|
"feature_privacy": "100% プライベートなデモ",
|
||||||
|
"hint": "左のメールをクリックして始めるか、ツアーを開始してください。"
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "ファイル",
|
"title": "ファイル",
|
||||||
@@ -2181,5 +2203,48 @@
|
|||||||
"export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください",
|
"export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください",
|
||||||
"export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください",
|
"export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください",
|
||||||
"incorrect_passphrase": "パスフレーズが正しくありません"
|
"incorrect_passphrase": "パスフレーズが正しくありません"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "ステップ {current} / {total}",
|
||||||
|
"skip": "ツアーをスキップ",
|
||||||
|
"back": "戻る",
|
||||||
|
"next": "次へ",
|
||||||
|
"finish": "完了",
|
||||||
|
"take_a_tour": "インターフェースのツアーを見る",
|
||||||
|
"restart_title": "紹介ツアー",
|
||||||
|
"restart_desc": "インターフェースのガイドツアーを再生する",
|
||||||
|
"restart_button": "ツアーを再開",
|
||||||
|
"sidebar_title": "メールボックス",
|
||||||
|
"sidebar_desc": "フォルダーサイドバーです。メールボックスをクリックしてメールを表示できます。フォルダーの作成、メールのドラッグ移動、未読数の確認ができます。",
|
||||||
|
"compose_title": "メールを作成",
|
||||||
|
"compose_desc": "ここをクリックして新しいメールを作成します。宛先、添付ファイルの追加やリッチテキスト書式が使えます。",
|
||||||
|
"search_title": "メールを検索",
|
||||||
|
"search_desc": "送信者、件名、内容で検索できます。フィルターアイコンをクリックすると、日付範囲、添付ファイル、スター付きメッセージなどの詳細オプションが使えます。",
|
||||||
|
"email_list_title": "メール一覧",
|
||||||
|
"email_list_desc": "メールがここに表示されます。クリックすると右側で読めます。チェックボックスで複数選択し、一括で移動、削除、タグ付けができます。",
|
||||||
|
"email_viewer_title": "閲覧パネル",
|
||||||
|
"email_viewer_desc": "選択したメールがここに開きます。ツールバーのボタンで返信、転送、アーカイブ、削除ができます。メールにスターやカラータグも付けられます。",
|
||||||
|
"keywords_title": "カラータグ",
|
||||||
|
"keywords_desc": "色分けされたタグでメールを整理できます。メールをタグにドラッグしてラベル付けするか、右クリックでタグを割り当てられます。",
|
||||||
|
"calendar_title": "カレンダー",
|
||||||
|
"calendar_desc": "カレンダーに切り替えてイベントを管理できます。イベントの作成、リマインダーの設定、日・週・月表示の切り替えができます。",
|
||||||
|
"contacts_title": "連絡先",
|
||||||
|
"contacts_desc": "アドレス帳がここにあります。連絡先のインポート、グループの作成、連絡先をクリックして詳細を確認できます。",
|
||||||
|
"settings_title": "設定",
|
||||||
|
"settings_desc": "すべてをカスタマイズできます:テーマ、表示密度、署名、フィルター、キーボードショートカット、カレンダー設定など。",
|
||||||
|
"shortcuts_title": "キーボードショートカット",
|
||||||
|
"shortcuts_desc": "パワーユーザー向けの機能です。いつでも ? を押すと利用可能なすべてのショートカットが表示されます。マウスを使わずにナビゲーション、作成、メール管理ができます。",
|
||||||
|
"calendar_view_title": "カレンダー表示",
|
||||||
|
"calendar_view_desc": "サンプルイベント付きのカレンダーです。ツールバーで日、週、月、アジェンダビューを切り替えられます。",
|
||||||
|
"contacts_list_title": "連絡先一覧",
|
||||||
|
"contacts_list_desc": "連絡先の一覧です。連絡先をクリックすると右側に詳細が表示されます。新しい連絡先の作成、vCardのインポート、グループへの整理もできます。",
|
||||||
|
"files_title": "ファイルストレージ",
|
||||||
|
"settings_tabs_title": "設定メニュー",
|
||||||
|
"settings_tabs_desc": "すべての設定カテゴリがここにあります。外観のカスタマイズ、IDの管理、メールフィルターの設定、カレンダーの構成など、多数の項目を調整できます。",
|
||||||
|
"files_desc": "ファイルブラウザでファイルのアップロード、整理、共有ができます。メールに統合されたパーソナルクラウドのようなものです。",
|
||||||
|
"demo_banner_title": "デモコントロール",
|
||||||
|
"demo_banner_desc": "デモモードです。すべてブラウザ内に保存されます。「デモをリセット」をクリックすると、いつでもクリーンなサンプルデータで再開できます。",
|
||||||
|
"quota_title": "ストレージ使用量",
|
||||||
|
"quota_desc": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-1
@@ -40,6 +40,12 @@
|
|||||||
"website": "Website",
|
"website": "Website",
|
||||||
"imprint": "Colofon",
|
"imprint": "Colofon",
|
||||||
"privacy_policy": "Privacybeleid",
|
"privacy_policy": "Privacybeleid",
|
||||||
|
"try_demo": "Demo proberen",
|
||||||
|
"demo_description": "Verken met voorbeeldgegevens — geen account nodig",
|
||||||
|
"demo_launching": "Demo starten...",
|
||||||
|
"demo_login_button": "Demo starten",
|
||||||
|
"demo_tagline": "Ervaar een complete e-mailclient. Geen account nodig.",
|
||||||
|
"demo_no_signup": "Geen registratie nodig — verken vrij met voorbeeldgegevens",
|
||||||
"oauth_completing": "Aanmelding voltooien...",
|
"oauth_completing": "Aanmelding voltooien...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Authenticatie mislukt",
|
"title": "Authenticatie mislukt",
|
||||||
@@ -104,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Zoekopdracht wissen",
|
"clear_search": "Zoekopdracht wissen",
|
||||||
"vacation_active": "Afwezigheidsmelder is actief",
|
"vacation_active": "Afwezigheidsmelder is actief",
|
||||||
|
"demo_banner": "Demomodus",
|
||||||
|
"demo_reset": "Resetten",
|
||||||
|
"demo_tour": "Rondleiding",
|
||||||
"tags": "Labels",
|
"tags": "Labels",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navigatie",
|
"nav_label": "Navigatie",
|
||||||
@@ -847,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
"description": "Bekijk je accountinformatie",
|
"description": "Bekijk je accountinformatie",
|
||||||
|
"name_label": "Weergavenaam",
|
||||||
|
"account_type_label": "Accounttype",
|
||||||
|
"demo_account": "Demoaccount",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "E-mailadres",
|
"label": "E-mailadres",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -2001,7 +2013,17 @@
|
|||||||
"tip_settings": "Pas uw ervaring aan in Instellingen",
|
"tip_settings": "Pas uw ervaring aan in Instellingen",
|
||||||
"got_it": "Begrepen",
|
"got_it": "Begrepen",
|
||||||
"settings": "Instellingen",
|
"settings": "Instellingen",
|
||||||
"dismiss": "Sluiten"
|
"dismiss": "Sluiten",
|
||||||
|
"start_tour": "Tour starten"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Welkom bij Bulwark Mail",
|
||||||
|
"description": "Ontdek een volwaardige webmail-client — rechtstreeks in je browser. Alle gegevens blijven op je apparaat, dus test gerust alles.",
|
||||||
|
"feature_email": "E-mails lezen en schrijven",
|
||||||
|
"feature_organize": "Tags, sterren en mappen",
|
||||||
|
"feature_shortcuts": "Sneltoetsen",
|
||||||
|
"feature_privacy": "100% privédemo",
|
||||||
|
"hint": "Klik links op een e-mail om te beginnen, of start de tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Bestanden",
|
"title": "Bestanden",
|
||||||
@@ -2181,5 +2203,48 @@
|
|||||||
"export_passphrase_desc": "Kies een wachtwoordzin om het geëxporteerde PKCS#12-bestand te beschermen",
|
"export_passphrase_desc": "Kies een wachtwoordzin om het geëxporteerde PKCS#12-bestand te beschermen",
|
||||||
"export_storage_desc": "Voer de opslagwachtwoordzin in om de sleutel voor export te ontsleutelen",
|
"export_storage_desc": "Voer de opslagwachtwoordzin in om de sleutel voor export te ontsleutelen",
|
||||||
"incorrect_passphrase": "Onjuiste wachtwoordzin"
|
"incorrect_passphrase": "Onjuiste wachtwoordzin"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Stap {current} van {total}",
|
||||||
|
"skip": "Tour overslaan",
|
||||||
|
"back": "Terug",
|
||||||
|
"next": "Volgende",
|
||||||
|
"finish": "Voltooien",
|
||||||
|
"take_a_tour": "Maak een rondleiding door de interface",
|
||||||
|
"restart_title": "Introductietour",
|
||||||
|
"restart_desc": "Bekijk de rondleiding door de interface opnieuw",
|
||||||
|
"restart_button": "Tour herstarten",
|
||||||
|
"sidebar_title": "Uw mailboxen",
|
||||||
|
"sidebar_desc": "Dit is uw mappenbalk. Klik op een mailbox om de e-mails te bekijken. U kunt mappen maken, e-mails tussen mappen slepen en ongelezen aantallen zien.",
|
||||||
|
"compose_title": "E-mail schrijven",
|
||||||
|
"compose_desc": "Klik hier om een nieuwe e-mail te schrijven. U kunt ontvangers, bijlagen toevoegen en rijke tekstopmaak gebruiken.",
|
||||||
|
"search_title": "Zoek in uw mail",
|
||||||
|
"search_desc": "Zoek op afzender, onderwerp of inhoud. Klik op het filtericoon voor geavanceerde opties zoals datumbereik, bijlagen en favoriete berichten.",
|
||||||
|
"email_list_title": "Uw e-maillijst",
|
||||||
|
"email_list_desc": "E-mails verschijnen hier. Klik op een e-mail om deze rechts te lezen. Gebruik het selectievakje om meerdere te selecteren, verplaats, verwijder of label ze vervolgens.",
|
||||||
|
"email_viewer_title": "Leesvenster",
|
||||||
|
"email_viewer_desc": "De geselecteerde e-mail opent hier. Beantwoord, doorstuur, archiveer of verwijder met de werkbalkknopen. U kunt ook e-mails markeren of kleurlabels toevoegen.",
|
||||||
|
"keywords_title": "Kleurlabels",
|
||||||
|
"keywords_desc": "Organiseer uw e-mail met kleurgecodeerde labels. Sleep een e-mail naar een label om het te markeren, of klik met de rechtermuisknop om labels toe te wijzen.",
|
||||||
|
"calendar_title": "Agenda",
|
||||||
|
"calendar_desc": "Schakel naar de agenda om uw evenementen te beheren. Maak evenementen aan, stel herinneringen in en bekijk dag-, week- of maandweergaven.",
|
||||||
|
"contacts_title": "Contacten",
|
||||||
|
"contacts_desc": "Uw adresboek bevindt zich hier. Importeer contacten, maak groepen aan en klik op een contact om de volledige details te bekijken.",
|
||||||
|
"settings_title": "Instellingen",
|
||||||
|
"settings_desc": "Pas alles aan: thema, dichtheid, handtekeningen, filters, sneltoetsen, agendainstellingen en meer.",
|
||||||
|
"shortcuts_title": "Sneltoetsen",
|
||||||
|
"shortcuts_desc": "Ervaren gebruikers zijn hier dol op. Druk op ? om alle beschikbare sneltoetsen te bekijken. Navigeer, schrijf en beheer e-mails zonder de muis aan te raken.",
|
||||||
|
"calendar_view_title": "Uw agenda",
|
||||||
|
"calendar_view_desc": "Hier is uw agenda met voorbeeldevenementen. Schakel tussen dag-, week-, maand- en agendaweergave via de werkbalk.",
|
||||||
|
"contacts_list_title": "Uw contacten",
|
||||||
|
"contacts_list_desc": "Hier zijn uw contacten. Klik op een contact om de details rechts te bekijken. U kunt ook nieuwe contacten aanmaken, vCards importeren of contacten in groepen organiseren.",
|
||||||
|
"files_title": "Bestandsopslag",
|
||||||
|
"settings_tabs_title": "Instellingenmenu",
|
||||||
|
"settings_tabs_desc": "Hier vindt u alle instellingscategorieën. Pas het uiterlijk aan, beheer identiteiten, stel e-mailfilters in, configureer uw agenda en nog veel meer.",
|
||||||
|
"files_desc": "Uw bestandsbrowser laat u bestanden uploaden, organiseren en delen — als een persoonlijke cloud geïntegreerd in uw mail.",
|
||||||
|
"demo_banner_title": "Demo-bediening",
|
||||||
|
"demo_banner_desc": "U bent in demomodus — alles blijft in uw browser. Klik op 'Demo resetten' om opnieuw te beginnen met schone voorbeeldgegevens.",
|
||||||
|
"quota_title": "Opslaggebruik",
|
||||||
|
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-1
@@ -40,6 +40,12 @@
|
|||||||
"website": "Site",
|
"website": "Site",
|
||||||
"imprint": "Informações legais",
|
"imprint": "Informações legais",
|
||||||
"privacy_policy": "Política de privacidade",
|
"privacy_policy": "Política de privacidade",
|
||||||
|
"try_demo": "Experimentar demo",
|
||||||
|
"demo_description": "Explore com dados de exemplo — sem conta necessária",
|
||||||
|
"demo_launching": "Iniciando demo...",
|
||||||
|
"demo_login_button": "Iniciar demo",
|
||||||
|
"demo_tagline": "Experimente um cliente de e-mail completo. Sem necessidade de conta.",
|
||||||
|
"demo_no_signup": "Sem registo — explore livremente com dados de exemplo",
|
||||||
"oauth_completing": "Concluindo login...",
|
"oauth_completing": "Concluindo login...",
|
||||||
"oauth_error": {
|
"oauth_error": {
|
||||||
"title": "Falha na autenticação",
|
"title": "Falha na autenticação",
|
||||||
@@ -104,6 +110,9 @@
|
|||||||
},
|
},
|
||||||
"clear_search": "Limpar busca",
|
"clear_search": "Limpar busca",
|
||||||
"vacation_active": "Resposta automática ativa",
|
"vacation_active": "Resposta automática ativa",
|
||||||
|
"demo_banner": "Modo de demonstração",
|
||||||
|
"demo_reset": "Repor",
|
||||||
|
"demo_tour": "Tour",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navegação",
|
"nav_label": "Navegação",
|
||||||
@@ -847,6 +856,9 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"title": "Conta",
|
"title": "Conta",
|
||||||
"description": "Visualize as informações da sua conta",
|
"description": "Visualize as informações da sua conta",
|
||||||
|
"name_label": "Nome de exibição",
|
||||||
|
"account_type_label": "Tipo de conta",
|
||||||
|
"demo_account": "Conta de demonstração",
|
||||||
"email": {
|
"email": {
|
||||||
"label": "Endereço de E-mail",
|
"label": "Endereço de E-mail",
|
||||||
"value": "{email}"
|
"value": "{email}"
|
||||||
@@ -2001,7 +2013,17 @@
|
|||||||
"tip_settings": "Personalize sua experiência nas Configurações",
|
"tip_settings": "Personalize sua experiência nas Configurações",
|
||||||
"got_it": "Entendi",
|
"got_it": "Entendi",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"dismiss": "Fechar"
|
"dismiss": "Fechar",
|
||||||
|
"start_tour": "Iniciar tour"
|
||||||
|
},
|
||||||
|
"demo_welcome": {
|
||||||
|
"title": "Bem-vindo ao Bulwark Mail",
|
||||||
|
"description": "Explore um cliente de webmail completo — diretamente no seu navegador. Todos os dados ficam no seu dispositivo, então teste tudo.",
|
||||||
|
"feature_email": "Ler e escrever e-mails",
|
||||||
|
"feature_organize": "Tags, estrelas e pastas",
|
||||||
|
"feature_shortcuts": "Atalhos de teclado",
|
||||||
|
"feature_privacy": "Demo 100% privada",
|
||||||
|
"hint": "Clique num e-mail à esquerda para começar, ou inicie o tour."
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Ficheiros",
|
"title": "Ficheiros",
|
||||||
@@ -2181,5 +2203,48 @@
|
|||||||
"export_passphrase_desc": "Escolha uma frase secreta para proteger o arquivo PKCS#12 exportado",
|
"export_passphrase_desc": "Escolha uma frase secreta para proteger o arquivo PKCS#12 exportado",
|
||||||
"export_storage_desc": "Insira a frase secreta de armazenamento para descriptografar a chave para exportação",
|
"export_storage_desc": "Insira a frase secreta de armazenamento para descriptografar a chave para exportação",
|
||||||
"incorrect_passphrase": "Frase secreta incorreta"
|
"incorrect_passphrase": "Frase secreta incorreta"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"step_counter": "Passo {current} de {total}",
|
||||||
|
"skip": "Pular tour",
|
||||||
|
"back": "Voltar",
|
||||||
|
"next": "Próximo",
|
||||||
|
"finish": "Finalizar",
|
||||||
|
"take_a_tour": "Faça um tour pela interface",
|
||||||
|
"restart_title": "Tour introdutório",
|
||||||
|
"restart_desc": "Rever o tour guiado da interface",
|
||||||
|
"restart_button": "Reiniciar tour",
|
||||||
|
"sidebar_title": "Suas caixas de correio",
|
||||||
|
"sidebar_desc": "Esta é a barra lateral de pastas. Clique em qualquer caixa para ver seus e-mails. Você pode criar pastas, arrastar e-mails entre elas e ver contadores de não lidos.",
|
||||||
|
"compose_title": "Escrever um e-mail",
|
||||||
|
"compose_desc": "Clique aqui para escrever um novo e-mail. Você pode adicionar destinatários, anexos e usar formatação de texto enriquecido.",
|
||||||
|
"search_title": "Pesquisar sua caixa",
|
||||||
|
"search_desc": "Pesquise por remetente, assunto ou conteúdo. Clique no ícone de filtro para opções avançadas como intervalo de datas, anexos e mensagens com estrela.",
|
||||||
|
"email_list_title": "Sua lista de e-mails",
|
||||||
|
"email_list_desc": "Os e-mails aparecem aqui. Clique em um para lê-lo à direita. Use a caixa de seleção para selecionar vários, depois mova, exclua ou etiquete em massa.",
|
||||||
|
"email_viewer_title": "Painel de leitura",
|
||||||
|
"email_viewer_desc": "O e-mail selecionado abre aqui. Responda, encaminhe, arquive ou exclua com os botões da barra de ferramentas. Você também pode marcar e-mails com estrela ou adicionar etiquetas coloridas.",
|
||||||
|
"keywords_title": "Etiquetas coloridas",
|
||||||
|
"keywords_desc": "Organize seus e-mails com etiquetas coloridas. Arraste um e-mail para uma etiqueta para marcá-lo, ou clique com o botão direito para atribuir etiquetas.",
|
||||||
|
"calendar_title": "Calendário",
|
||||||
|
"calendar_desc": "Mude para o calendário para gerenciar seus eventos. Crie eventos, defina lembretes e visualize os layouts de dia, semana ou mês.",
|
||||||
|
"contacts_title": "Contatos",
|
||||||
|
"contacts_desc": "Seu livro de endereços fica aqui. Importe contatos, crie grupos e clique em qualquer contato para ver seus detalhes completos.",
|
||||||
|
"settings_title": "Configurações",
|
||||||
|
"settings_desc": "Personalize tudo: tema, densidade, assinaturas, filtros, atalhos de teclado, padrões do calendário e mais.",
|
||||||
|
"shortcuts_title": "Atalhos de teclado",
|
||||||
|
"shortcuts_desc": "Usuários avançados adoram isso. Pressione ? a qualquer momento para ver todos os atalhos disponíveis. Navegue, escreva e gerencie e-mails sem tocar no mouse.",
|
||||||
|
"calendar_view_title": "Seu calendário",
|
||||||
|
"calendar_view_desc": "Aqui está seu calendário com eventos de exemplo. Você pode alternar entre as visualizações de dia, semana, mês e agenda usando a barra de ferramentas.",
|
||||||
|
"contacts_list_title": "Seus contatos",
|
||||||
|
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
|
||||||
|
"files_title": "Armazenamento de ficheiros",
|
||||||
|
"settings_tabs_title": "Menu de configurações",
|
||||||
|
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
|
||||||
|
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros — como uma nuvem pessoal integrada no seu e-mail.",
|
||||||
|
"demo_banner_title": "Controlos de demonstração",
|
||||||
|
"demo_banner_desc": "Está no modo de demonstração — tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
|
||||||
|
"quota_title": "Utilização do armazenamento",
|
||||||
|
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-2
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { JMAPClient } from '@/lib/jmap/client';
|
import { JMAPClient } from '@/lib/jmap/client';
|
||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useIdentityStore } from './identity-store';
|
import { useIdentityStore } from './identity-store';
|
||||||
import { useContactStore } from './contact-store';
|
import { useContactStore } from './contact-store';
|
||||||
import { useVacationStore } from './vacation-store';
|
import { useVacationStore } from './vacation-store';
|
||||||
@@ -21,7 +22,7 @@ interface AuthState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
serverUrl: string | null;
|
serverUrl: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
client: JMAPClient | null;
|
client: IJMAPClient | null;
|
||||||
identities: Identity[];
|
identities: Identity[];
|
||||||
primaryIdentity: Identity | null;
|
primaryIdentity: Identity | null;
|
||||||
authMode: 'basic' | 'oauth';
|
authMode: 'basic' | 'oauth';
|
||||||
@@ -30,9 +31,11 @@ interface AuthState {
|
|||||||
tokenExpiresAt: number | null;
|
tokenExpiresAt: number | null;
|
||||||
connectionLost: boolean;
|
connectionLost: boolean;
|
||||||
activeAccountId: string | null;
|
activeAccountId: string | null;
|
||||||
|
isDemoMode: boolean;
|
||||||
|
|
||||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
||||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||||
|
loginDemo: () => Promise<boolean>;
|
||||||
refreshAccessToken: () => Promise<string | null>;
|
refreshAccessToken: () => Promise<string | null>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
logoutAll: () => void;
|
logoutAll: () => void;
|
||||||
@@ -140,7 +143,7 @@ function markSessionExpired(): void {
|
|||||||
saveRedirectAfterLogin();
|
saveRedirectAfterLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeFeatureStores(client: JMAPClient): void {
|
function initializeFeatureStores(client: IJMAPClient): void {
|
||||||
if (client.supportsContacts()) {
|
if (client.supportsContacts()) {
|
||||||
const contactStore = useContactStore.getState();
|
const contactStore = useContactStore.getState();
|
||||||
contactStore.setSupportsSync(true);
|
contactStore.setSupportsSync(true);
|
||||||
@@ -242,6 +245,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
activeAccountId: null,
|
activeAccountId: null,
|
||||||
|
isDemoMode: false,
|
||||||
|
|
||||||
login: async (serverUrl, username, password, totp, rememberMe) => {
|
login: async (serverUrl, username, password, totp, rememberMe) => {
|
||||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||||
@@ -355,6 +359,68 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loginDemo: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
// Clear all store data before re-initializing with fresh demo data
|
||||||
|
clearAllStores();
|
||||||
|
|
||||||
|
const { DemoJMAPClient } = await import('@/lib/demo/demo-client');
|
||||||
|
const client = new DemoJMAPClient();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const username = client.getUsername();
|
||||||
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||||
|
initializeFeatureStores(client);
|
||||||
|
|
||||||
|
// Register a demo account entry so the account-switcher shows
|
||||||
|
// proper avatar/name instead of a "?" placeholder.
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
const demoAccountId = accountStore.addAccount({
|
||||||
|
label: primaryIdentity?.name || 'Demo User',
|
||||||
|
serverUrl: 'https://demo.example.com',
|
||||||
|
username,
|
||||||
|
authMode: 'basic',
|
||||||
|
rememberMe: false,
|
||||||
|
displayName: primaryIdentity?.name || 'Demo User',
|
||||||
|
email: primaryIdentity?.email || username,
|
||||||
|
lastLoginAt: Date.now(),
|
||||||
|
isConnected: true,
|
||||||
|
hasError: false,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
accountStore.setActiveAccount(demoAccountId);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
serverUrl: 'demo.example.com',
|
||||||
|
username,
|
||||||
|
client,
|
||||||
|
identities,
|
||||||
|
primaryIdentity,
|
||||||
|
authMode: 'basic',
|
||||||
|
rememberMe: false,
|
||||||
|
accessToken: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: demoAccountId,
|
||||||
|
isDemoMode: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Demo login error:', error);
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: 'generic',
|
||||||
|
isAuthenticated: false,
|
||||||
|
client: null,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
|
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
@@ -512,12 +578,39 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
const wasDemoMode = state.isDemoMode;
|
||||||
const wasOAuth = state.authMode === 'oauth';
|
const wasOAuth = state.authMode === 'oauth';
|
||||||
const accountId = state.activeAccountId;
|
const accountId = state.activeAccountId;
|
||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
const account = accountId ? accountStore.getAccountById(accountId) : null;
|
const account = accountId ? accountStore.getAccountById(accountId) : null;
|
||||||
const slot = account?.cookieSlot ?? 0;
|
const slot = account?.cookieSlot ?? 0;
|
||||||
|
|
||||||
|
// Demo mode: simple cleanup, no network calls
|
||||||
|
if (wasDemoMode) {
|
||||||
|
set({ client: null });
|
||||||
|
state.client?.disconnect();
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
serverUrl: null,
|
||||||
|
username: null,
|
||||||
|
client: null,
|
||||||
|
identities: [],
|
||||||
|
primaryIdentity: null,
|
||||||
|
authMode: 'basic',
|
||||||
|
rememberMe: false,
|
||||||
|
accessToken: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
connectionLost: false,
|
||||||
|
error: null,
|
||||||
|
activeAccountId: null,
|
||||||
|
isDemoMode: false,
|
||||||
|
});
|
||||||
|
localStorage.removeItem('auth-storage');
|
||||||
|
clearAllStores();
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearRefreshTimer(accountId ?? undefined);
|
clearRefreshTimer(accountId ?? undefined);
|
||||||
|
|
||||||
// Null out the client BEFORE disconnecting so the page doesn't fire
|
// Null out the client BEFORE disconnecting so the page doesn't fire
|
||||||
@@ -902,6 +995,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
const accounts = accountStore.accounts;
|
const accounts = accountStore.accounts;
|
||||||
|
|
||||||
|
// If the only account is the demo account, re-initialize demo mode
|
||||||
|
// instead of trying to restore a server session (which doesn't exist).
|
||||||
|
if (accounts.length === 1 && accounts[0].serverUrl === 'https://demo.example.com') {
|
||||||
|
await get().loginDemo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Multi-account restoration: restore all registered accounts
|
// Multi-account restoration: restore all registered accounts
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
// Null out client so the page doesn't fire data-loading effects
|
// Null out client so the page doesn't fire data-loading effects
|
||||||
|
|||||||
+16
-16
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { JMAPClient } from '@/lib/jmap/client';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||||
@@ -37,17 +37,17 @@ interface CalendarStore {
|
|||||||
dateRange: { start: string; end: string } | null;
|
dateRange: { start: string; end: string } | null;
|
||||||
|
|
||||||
setSupported: (supported: boolean) => void;
|
setSupported: (supported: boolean) => void;
|
||||||
fetchCalendars: (client: JMAPClient) => Promise<void>;
|
fetchCalendars: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>;
|
fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
|
||||||
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
||||||
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||||
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||||
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
|
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
|
||||||
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||||
updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
||||||
createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
|
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
|
||||||
removeCalendar: (client: JMAPClient, calendarId: string) => Promise<void>;
|
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
|
||||||
clearCalendarEvents: (client: JMAPClient, calendarId: string) => Promise<number>;
|
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
|
||||||
setSelectedDate: (date: Date) => void;
|
setSelectedDate: (date: Date) => void;
|
||||||
setViewMode: (mode: CalendarViewMode) => void;
|
setViewMode: (mode: CalendarViewMode) => void;
|
||||||
toggleCalendarVisibility: (calendarId: string) => void;
|
toggleCalendarVisibility: (calendarId: string) => void;
|
||||||
@@ -56,10 +56,10 @@ interface CalendarStore {
|
|||||||
|
|
||||||
// iCal subscriptions
|
// iCal subscriptions
|
||||||
icalSubscriptions: ICalSubscription[];
|
icalSubscriptions: ICalSubscription[];
|
||||||
addICalSubscription: (client: JMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
|
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
|
||||||
removeICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
|
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
||||||
refreshICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
|
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
||||||
refreshAllSubscriptions: (client: JMAPClient) => Promise<void>;
|
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
|
||||||
isSubscriptionCalendar: (calendarId: string) => boolean;
|
isSubscriptionCalendar: (calendarId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||||
import type { JMAPClient } from '@/lib/jmap/client';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
export function getContactDisplayName(contact: ContactCard): string {
|
export function getContactDisplayName(contact: ContactCard): string {
|
||||||
if (contact.name?.components) {
|
if (contact.name?.components) {
|
||||||
@@ -47,11 +47,11 @@ interface ContactStore {
|
|||||||
lastSelectedContactId: string | null;
|
lastSelectedContactId: string | null;
|
||||||
activeTab: 'all' | 'groups';
|
activeTab: 'all' | 'groups';
|
||||||
|
|
||||||
fetchContacts: (client: JMAPClient) => Promise<void>;
|
fetchContacts: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
|
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
|
||||||
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
||||||
updateContact: (client: JMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
|
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
|
||||||
deleteContact: (client: JMAPClient, id: string) => Promise<void>;
|
deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
|
||||||
|
|
||||||
addLocalContact: (contact: ContactCard) => void;
|
addLocalContact: (contact: ContactCard) => void;
|
||||||
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
|
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
|
||||||
@@ -68,21 +68,21 @@ interface ContactStore {
|
|||||||
getGroups: () => ContactCard[];
|
getGroups: () => ContactCard[];
|
||||||
getIndividuals: () => ContactCard[];
|
getIndividuals: () => ContactCard[];
|
||||||
getGroupMembers: (groupId: string) => ContactCard[];
|
getGroupMembers: (groupId: string) => ContactCard[];
|
||||||
createGroup: (client: JMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
|
createGroup: (client: IJMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
|
||||||
updateGroup: (client: JMAPClient | null, groupId: string, name: string) => Promise<void>;
|
updateGroup: (client: IJMAPClient | null, groupId: string, name: string) => Promise<void>;
|
||||||
addMembersToGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
|
addMembersToGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
|
||||||
removeMembersFromGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
|
removeMembersFromGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
|
||||||
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>;
|
deleteGroup: (client: IJMAPClient | null, groupId: string) => Promise<void>;
|
||||||
|
|
||||||
toggleContactSelection: (id: string) => void;
|
toggleContactSelection: (id: string) => void;
|
||||||
selectRangeContacts: (targetId: string, sortedIds: string[]) => void;
|
selectRangeContacts: (targetId: string, sortedIds: string[]) => void;
|
||||||
selectAllContacts: (ids: string[]) => void;
|
selectAllContacts: (ids: string[]) => void;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
|
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
|
||||||
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||||
moveContactToAddressBook: (client: JMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||||
|
|
||||||
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useContactStore = create<ContactStore>()(
|
export const useContactStore = create<ContactStore>()(
|
||||||
|
|||||||
+32
-32
@@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
||||||
import { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||||
@@ -48,7 +48,7 @@ interface EmailStore {
|
|||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
setQuota: (quota: { used: number; total: number } | null) => void;
|
setQuota: (quota: { used: number; total: number } | null) => void;
|
||||||
selectKeyword: (keyword: string | null) => void;
|
selectKeyword: (keyword: string | null) => void;
|
||||||
fetchTagCounts: (client: JMAPClient) => Promise<void>;
|
fetchTagCounts: (client: IJMAPClient) => Promise<void>;
|
||||||
toggleEmailSelection: (emailId: string) => void;
|
toggleEmailSelection: (emailId: string) => void;
|
||||||
selectRangeEmails: (targetEmailId: string) => void;
|
selectRangeEmails: (targetEmailId: string) => void;
|
||||||
lastSelectedEmailId: string | null;
|
lastSelectedEmailId: string | null;
|
||||||
@@ -56,54 +56,54 @@ interface EmailStore {
|
|||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
|
|
||||||
// JMAP operations
|
// JMAP operations
|
||||||
fetchMailboxes: (client: JMAPClient) => Promise<void>;
|
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise<void>;
|
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
|
||||||
loadMoreEmails: (client: JMAPClient) => Promise<void>;
|
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
|
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||||
fetchQuota: (client: JMAPClient) => Promise<void>;
|
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||||
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => Promise<void>;
|
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => Promise<void>;
|
||||||
sendRawEmail: (client: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
||||||
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||||
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||||
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||||
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
|
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
||||||
advancedSearch: (client: JMAPClient) => Promise<void>;
|
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
||||||
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
||||||
clearSearchFilters: () => void;
|
clearSearchFilters: () => void;
|
||||||
toggleAdvancedSearch: () => void;
|
toggleAdvancedSearch: () => void;
|
||||||
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
|
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
|
||||||
|
|
||||||
// Batch operations
|
// Batch operations
|
||||||
batchMarkAsRead: (client: JMAPClient, read: boolean) => Promise<void>;
|
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
|
||||||
batchDelete: (client: JMAPClient) => Promise<void>;
|
batchDelete: (client: IJMAPClient) => Promise<void>;
|
||||||
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
|
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||||
|
|
||||||
// Spam operations
|
// Spam operations
|
||||||
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
|
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
|
||||||
markAsSpam: (client: JMAPClient, emailId: string) => Promise<void>;
|
markAsSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
|
||||||
undoSpam: (client: JMAPClient, emailId: string) => Promise<void>;
|
undoSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
|
||||||
batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
|
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
|
||||||
batchUndoSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
|
batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
|
||||||
|
|
||||||
// Push notification handlers
|
// Push notification handlers
|
||||||
setPushConnected: (connected: boolean) => void;
|
setPushConnected: (connected: boolean) => void;
|
||||||
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
|
handleStateChange: (change: StateChange, client: IJMAPClient) => Promise<void>;
|
||||||
refreshCurrentMailbox: (client: JMAPClient) => Promise<void>;
|
refreshCurrentMailbox: (client: IJMAPClient) => Promise<void>;
|
||||||
handleNewEmailNotification: (email: Email) => void;
|
handleNewEmailNotification: (email: Email) => void;
|
||||||
clearNewEmailNotification: () => void;
|
clearNewEmailNotification: () => void;
|
||||||
|
|
||||||
// Thread expansion actions
|
// Thread expansion actions
|
||||||
toggleThreadExpansion: (threadId: string) => void;
|
toggleThreadExpansion: (threadId: string) => void;
|
||||||
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>;
|
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
|
||||||
collapseAllThreads: () => void;
|
collapseAllThreads: () => void;
|
||||||
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
||||||
|
|
||||||
// Mailbox management
|
// Mailbox management
|
||||||
createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise<void>;
|
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
|
||||||
renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise<void>;
|
renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise<void>;
|
||||||
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
|
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||||
setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
||||||
emptyMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
|
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||||
|
|
||||||
// Mock data for demo
|
// Mock data for demo
|
||||||
loadMockData: () => void;
|
loadMockData: () => void;
|
||||||
@@ -1033,7 +1033,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
batchUndoSpam: async (client: JMAPClient, emailIds: string[]) => {
|
batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => {
|
||||||
const { mailboxes, selectedMailbox } = get();
|
const { mailboxes, selectedMailbox } = get();
|
||||||
|
|
||||||
// Find inbox (batch operations don't preserve original mailboxes)
|
// Find inbox (batch operations don't preserve original mailboxes)
|
||||||
@@ -1521,4 +1521,4 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
mailboxes: mockMailboxes,
|
mailboxes: mockMailboxes,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { JMAPClient } from '@/lib/jmap/client';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import type { FileNode } from '@/lib/jmap/types';
|
import type { FileNode } from '@/lib/jmap/types';
|
||||||
|
|
||||||
export interface FileResource {
|
export interface FileResource {
|
||||||
@@ -47,7 +47,7 @@ interface FileState {
|
|||||||
supportsFiles: boolean | null;
|
supportsFiles: boolean | null;
|
||||||
selectedResources: Set<string>;
|
selectedResources: Set<string>;
|
||||||
uploadProgress: UploadProgress | null;
|
uploadProgress: UploadProgress | null;
|
||||||
client: JMAPClient | null;
|
client: IJMAPClient | null;
|
||||||
clipboard: ClipboardState | null;
|
clipboard: ClipboardState | null;
|
||||||
uploadAbortController: AbortController | null;
|
uploadAbortController: AbortController | null;
|
||||||
favorites: string[];
|
favorites: string[];
|
||||||
@@ -55,7 +55,7 @@ interface FileState {
|
|||||||
lastAction: UndoAction | null;
|
lastAction: UndoAction | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initClient: (client: JMAPClient) => void;
|
initClient: (client: IJMAPClient) => void;
|
||||||
checkSupport: () => Promise<boolean>;
|
checkSupport: () => Promise<boolean>;
|
||||||
navigate: (parentId: string | null, name?: string) => Promise<void>;
|
navigate: (parentId: string | null, name?: string) => Promise<void>;
|
||||||
navigateByPath: (path: string) => Promise<void>;
|
navigateByPath: (path: string) => Promise<void>;
|
||||||
@@ -178,7 +178,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
|
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
initClient: (client: JMAPClient) => {
|
initClient: (client: IJMAPClient) => {
|
||||||
set({ client });
|
set({ client });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { JMAPClient } from '@/lib/jmap/client';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
import type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||||
import { parseScript } from '@/lib/sieve/parser';
|
import { parseScript } from '@/lib/sieve/parser';
|
||||||
import { generateScript } from '@/lib/sieve/generator';
|
import { generateScript } from '@/lib/sieve/generator';
|
||||||
@@ -17,9 +17,9 @@ interface FilterStore {
|
|||||||
rawScript: string;
|
rawScript: string;
|
||||||
|
|
||||||
setSupported: (supported: boolean) => void;
|
setSupported: (supported: boolean) => void;
|
||||||
fetchFilters: (client: JMAPClient) => Promise<void>;
|
fetchFilters: (client: IJMAPClient) => Promise<void>;
|
||||||
saveFilters: (client: JMAPClient) => Promise<void>;
|
saveFilters: (client: IJMAPClient) => Promise<void>;
|
||||||
validateScript: (client: JMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
|
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
|
||||||
addRule: (rule: FilterRule) => void;
|
addRule: (rule: FilterRule) => void;
|
||||||
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
|
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
|
||||||
deleteRule: (ruleId: string) => void;
|
deleteRule: (ruleId: string) => void;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { JMAPClient } from '@/lib/jmap/client';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface VacationStore {
|
interface VacationStore {
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@@ -13,8 +13,8 @@ interface VacationStore {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
|
|
||||||
fetchVacationResponse: (client: JMAPClient) => Promise<void>;
|
fetchVacationResponse: (client: IJMAPClient) => Promise<void>;
|
||||||
updateVacationResponse: (client: JMAPClient, updates: {
|
updateVacationResponse: (client: IJMAPClient, updates: {
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
fromDate?: string | null;
|
fromDate?: string | null;
|
||||||
toDate?: string | null;
|
toDate?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user