# READ CAREFULLY THEN REMOVE

Remove bullet points that are not relevant.

PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI.

- Pull requests that do not follow these guidelines will be closed without review or comment.
- If you use AI to write your PR description your pr will be close without review or comment.
- If you are unsure about anything, feel free to ask for clarification.

## Description

Please provide a clear description of your changes.

---

## Type of Change

Please delete options that are not relevant.

- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ]  New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature with breaking changes)
- [ ] 📝 Documentation update
- [ ] 🎨 UI/UX improvement
- [ ] 🔒 Security enhancement
- [ ]  Performance improvement

## Areas Affected

Please check all that apply:

- [ ] Email Integration (Gmail, IMAP, etc.)
- [ ] User Interface/Experience
- [ ] Authentication/Authorization
- [ ] Data Storage/Management
- [ ] API Endpoints
- [ ] Documentation
- [ ] Testing Infrastructure
- [ ] Development Workflow
- [ ] Deployment/Infrastructure

## Testing Done

Describe the tests you've done:

- [ ] Unit tests added/updated
- [ ] Integration tests added/updated
- [ ] Manual testing performed
- [ ] Cross-browser testing (if UI changes)
- [ ] Mobile responsiveness verified (if UI changes)

## Security Considerations

For changes involving data or authentication:

- [ ] No sensitive data is exposed
- [ ] Authentication checks are in place
- [ ] Input validation is implemented
- [ ] Rate limiting is considered (if applicable)

## Checklist

- [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in complex areas
- [ ] I have updated the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix/feature works
- [ ] All tests pass locally
- [ ] Any dependent changes are merged and published

## Additional Notes

Add any other context about the pull request here.

## Screenshots/Recordings

Add screenshots or recordings here if applicable.

---

_By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Introduced a new localization system replacing translation hooks with direct message function calls.
  * Added localization project configuration and Inlang plugins for improved message formatting.

* **Bug Fixes**
  * Enhanced pluralization handling across all supported languages for accurate message display.

* **Refactor**
  * Unified translation and locale management across components by removing hook-based translations.
  * Removed obsolete navigation and email signature settings pages.
  * Simplified query and server provider logic, removing connection-specific and internationalization context code.
  * Disabled server-side rendering in the mail app.
  * Removed Cloudflare Worker request handler and related environment augmentations.

* **Chores**
  * Updated dependencies and scripts, adding Inlang CLI and removing unused packages.
  * Cleaned up configuration files including Vite, Wrangler, and i18n settings.
  * Added `.gitignore` for localization cache.
  * Updated static asset handling and environment configurations for Cloudflare deployments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Adam
2025-06-30 11:37:18 -07:00
committed by GitHub
parent 59c18be801
commit 2fbaf7b312
79 changed files with 3339 additions and 2962 deletions

View File

@@ -1,8 +1,8 @@
import { TriangleAlert } from 'lucide-react';
import { useTranslations } from 'use-intl';
import { useQueryState } from 'nuqs';
import { useEffect } from 'react';
import { toast } from 'sonner';
import { m } from '@/paraglide/messages';
const errorMessages = ['required_scopes_missing'] as const;
@@ -19,11 +19,10 @@ const isErrorMessage = (error: string): error is (typeof errorMessages)[number]
const ErrorMessage = () => {
const [error] = useQueryState('error');
const t = useTranslations();
useEffect(() => {
if (error && isErrorToast(error)) {
toast.error(t(`errorMessages.${error}`));
toast.error(m[`errorMessages.${error}`]());
}
});
@@ -36,7 +35,7 @@ const ErrorMessage = () => {
<div className="flex items-center">
<TriangleAlert size={28} />
<p className="ml-2 text-sm text-black/80 dark:text-white/80">
{t(`errorMessages.${error}`)}
{m[`errorMessages.${error}`]()}
</p>
</div>
</div>

View File

@@ -1,11 +1,10 @@
import { Navigate, useLoaderData, useNavigate } from 'react-router';
import { useLoaderData, useNavigate } from 'react-router';
import { useTRPC } from '@/providers/query-provider';
import { MailLayout } from '@/components/mail/mail';
import { useQuery } from '@tanstack/react-query';
import { useLabels } from '@/hooks/use-labels';
import { authProxy } from '@/lib/auth-proxy';
import { useEffect, useState } from 'react';
import type { Route } from './+types/page';
import { Loader2 } from 'lucide-react';
const ALLOWED_FOLDERS = ['inbox', 'draft', 'sent', 'spam', 'bin', 'archive'];
@@ -23,20 +22,15 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs)
export default function MailPage() {
const { folder } = useLoaderData<typeof clientLoader>();
const navigate = useNavigate();
const trpc = useTRPC();
const [isLabelValid, setIsLabelValid] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLabelValid, setIsLabelValid] = useState<boolean | null>(true);
const isStandardFolder = ALLOWED_FOLDERS.includes(folder);
const { data: userLabels, isLoading: isLoadingLabels } = useQuery(
trpc.labels.list.queryOptions(void 0),
);
const { data: userLabels, isLoading: isLoadingLabels } = useLabels();
useEffect(() => {
if (isStandardFolder) {
setIsLabelValid(true);
setIsLoading(false);
return;
}
@@ -55,7 +49,6 @@ export default function MailPage() {
const labelExists = checkLabelExists(userLabels);
setIsLabelValid(labelExists);
setIsLoading(false);
if (!labelExists) {
const timer = setTimeout(() => {
@@ -65,18 +58,9 @@ export default function MailPage() {
}
} else {
setIsLabelValid(false);
setIsLoading(false);
}
}, [folder, userLabels, isLoadingLabels, isStandardFolder, navigate]);
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
);
}
if (!isLabelValid) {
return (
<div className="flex h-screen w-full flex-col items-center justify-center">

View File

@@ -1,5 +1,3 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect(`/mail/inbox`);
export function clientLoader() {
return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`);
}

View File

@@ -3,7 +3,7 @@ import ConnectionsPage from '../connections/page';
import AppearancePage from '../appearance/page';
import ShortcutsPage from '../shortcuts/page';
import SecurityPage from '../security/page';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import GeneralPage from '../general/page';
import { useParams } from 'react-router';
import LabelsPage from '../labels/page';
@@ -21,12 +21,12 @@ const settingsPages: Record<string, React.ComponentType> = {
export default function SettingsPage() {
const params = useParams();
const section = params.settings?.[0] || 'general';
const t = useTranslations();
const SettingsComponent = settingsPages[section];
if (!SettingsComponent) {
return <div>{t('pages.error.settingsNotFound')}</div>;
return <div>{m['pages.error.settingsNotFound']()}</div>;
}
return <SettingsComponent />;

View File

@@ -15,14 +15,13 @@ import {
} from '@/components/ui/select';
import { SettingsCard } from '@/components/settings/settings-card';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageKey } from '@/config/navigation';
import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { useSettings } from '@/hooks/use-settings';
import { Laptop, Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'use-intl';
import { useForm } from 'react-hook-form';
import { m } from '@/paraglide/messages';
import { useTheme } from 'next-themes';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -36,7 +35,7 @@ type Theme = 'dark' | 'light' | 'system';
export default function AppearancePage() {
const [isSaving, setIsSaving] = useState(false);
const t = useTranslations();
const { data, refetch } = useSettings();
const { theme, systemTheme, resolvedTheme, setTheme } = useTheme();
const trpc = useTRPC();
@@ -79,8 +78,8 @@ export default function AppearancePage() {
colorTheme: values.colorTheme as Theme,
}),
{
success: t('common.settings.saved'),
error: t('common.settings.failedToSave'),
success: m['common.settings.saved'](),
error: m['common.settings.failedToSave'](),
finally: async () => {
await refetch();
setIsSaving(false);
@@ -95,11 +94,11 @@ export default function AppearancePage() {
return (
<div className="grid gap-6">
<SettingsCard
title={t('pages.settings.appearance.title')}
description={t('pages.settings.appearance.description')}
title={m['pages.settings.appearance.title']()}
description={m['pages.settings.appearance.description']()}
footer={
<Button type="submit" form="appearance-form" disabled={isSaving}>
{isSaving ? t('common.actions.saving') : t('common.actions.saveChanges')}
{isSaving ? m['common.actions.saving']() : m['common.actions.saveChanges']()}
</Button>
}
>
@@ -113,7 +112,7 @@ export default function AppearancePage() {
name="colorTheme"
render={({ field }) => (
<FormItem>
<FormLabel>{t('pages.settings.appearance.theme')}</FormLabel>
<FormLabel>{m['pages.settings.appearance.theme']()}</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
@@ -127,7 +126,7 @@ export default function AppearancePage() {
{theme === 'dark' && <Moon className="h-4 w-4" />}
{theme === 'light' && <Sun className="h-4 w-4" />}
{theme === 'system' && <Laptop className="h-4 w-4" />}
{t(`common.themes.${theme}` as MessageKey)}
{m[`common.themes.${theme}` as MessageKey]()}
</div>
</SelectValue>
</SelectTrigger>
@@ -135,19 +134,19 @@ export default function AppearancePage() {
<SelectItem value="dark">
<div className="flex items-center gap-2">
<Moon className="h-4 w-4" />
{t('common.themes.dark')}
{m['common.themes.dark']()}
</div>
</SelectItem>
<SelectItem value="system">
<div className="flex items-center gap-2">
<Laptop className="h-4 w-4" />
{t('common.themes.system')}
{m['common.themes.system']()}
</div>
</SelectItem>
<SelectItem value="light">
<div className="flex items-center gap-2">
<Sun className="h-4 w-4" />
{t('common.themes.light')}
{m['common.themes.light']()}
</div>
</SelectItem>
</SelectContent>

View File

@@ -22,7 +22,7 @@ import { useBilling } from '@/hooks/use-billing';
import { emailProviders } from '@/lib/constants';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useQueryState } from 'nuqs';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -31,7 +31,7 @@ export default function ConnectionsPage() {
const { data, isLoading, refetch: refetchConnections } = useConnections();
const { refetch } = useSession();
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
const t = useTranslations();
const trpc = useTRPC();
const { mutateAsync: deleteConnection } = useMutation(trpc.connections.delete.mutationOptions());
const [{ refetch: refetchThreads }] = useThreads();
@@ -43,11 +43,11 @@ export default function ConnectionsPage() {
{
onError: (error) => {
console.error('Error disconnecting account:', error);
toast.error(t('pages.settings.connections.disconnectError'));
toast.error(m['pages.settings.connections.disconnectError']());
},
},
);
toast.success(t('pages.settings.connections.disconnectSuccess'));
toast.success(m['pages.settings.connections.disconnectSuccess']());
void refetchConnections();
refetch();
void refetchThreads();
@@ -56,8 +56,8 @@ export default function ConnectionsPage() {
return (
<div className="grid gap-6">
<SettingsCard
title={t('pages.settings.connections.title')}
description={t('pages.settings.connections.description')}
title={m['pages.settings.connections.title']()}
description={m['pages.settings.connections.description']()}
>
<div className="space-y-6">
{isLoading ? (
@@ -141,7 +141,7 @@ export default function ConnectionsPage() {
<>
<div>
<Badge variant="destructive">
{t('pages.settings.connections.disconnected')}
{m['pages.settings.connections.disconnected']()}
</Badge>
</div>
<Button
@@ -155,7 +155,7 @@ export default function ConnectionsPage() {
}}
>
<Unplug className="size-4" />
{t('pages.settings.connections.reconnect')}
{m['pages.settings.connections.reconnect']()}
</Button>
</>
) : null}
@@ -172,21 +172,21 @@ export default function ConnectionsPage() {
<DialogContent showOverlay>
<DialogHeader>
<DialogTitle>
{t('pages.settings.connections.disconnectTitle')}
{m['pages.settings.connections.disconnectTitle']()}
</DialogTitle>
<DialogDescription>
{t('pages.settings.connections.disconnectDescription')}
{m['pages.settings.connections.disconnectDescription']()}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4">
<DialogClose asChild>
<Button variant="outline">
{t('pages.settings.connections.cancel')}
{m['pages.settings.connections.cancel']()}
</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={() => disconnectAccount(connection.id)}>
{t('pages.settings.connections.remove')}
{m['pages.settings.connections.remove']()}
</Button>
</DialogClose>
</div>
@@ -208,7 +208,7 @@ export default function ConnectionsPage() {
>
<Plus className="absolute left-2 h-4 w-4" />
<span className="whitespace-nowrap pl-7 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{t('pages.settings.connections.addEmail')}
{m['pages.settings.connections.addEmail']()}
</span>
</Button>
</AddConnectionDialog>
@@ -220,7 +220,7 @@ export default function ConnectionsPage() {
>
<Plus className="absolute left-2 h-4 w-4" />
<span className="whitespace-nowrap pl-7 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{t('pages.settings.connections.addEmail')}
{m['pages.settings.connections.addEmail']()}
</span>
</Button>
)}

View File

@@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AlertTriangle } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useTranslations } from 'use-intl';
import { useForm } from 'react-hook-form';
import { m } from '@/paraglide/messages';
import { clear } from 'idb-keyval';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -27,13 +27,12 @@ const CONFIRMATION_TEXT = 'DELETE';
const formSchema = z.object({
confirmText: z.string().refine((val) => val === CONFIRMATION_TEXT, {
message: `Please type ${CONFIRMATION_TEXT} to confirm`,
message: m['pages.settings.dangerZone.confirmation'](),
}),
});
function DeleteAccountDialog() {
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
const navigate = useNavigate();
const trpc = useTRPC();
const { refetch } = useSession();
@@ -48,7 +47,7 @@ function DeleteAccountDialog() {
async function onSubmit(values: z.infer<typeof formSchema>) {
if (values.confirmText !== CONFIRMATION_TEXT)
return toast.error(`Please type ${CONFIRMATION_TEXT} to confirm`);
return toast.error(m['pages.settings.dangerZone.confirmation']());
await deleteAccount(void 0, {
onSuccess: async ({ success, message }) => {
@@ -59,14 +58,14 @@ function DeleteAccountDialog() {
await clear();
} catch (error) {
console.error('Failed to delete account:', error);
toast.error('Failed to delete account');
toast.error(m['pages.settings.dangerZone.error']());
}
toast.success('Account deleted successfully');
toast.success(m['pages.settings.dangerZone.deleted']());
window.location.href = '/';
},
onError: (error) => {
console.error('Failed to delete account:', error);
toast.error('Failed to delete account');
toast.error(m['pages.settings.dangerZone.error']());
},
onSettled: () => form.reset(),
});
@@ -75,17 +74,17 @@ function DeleteAccountDialog() {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="destructive">{t('pages.settings.dangerZone.deleteAccount')}</Button>
<Button variant="destructive">{m['pages.settings.dangerZone.deleteAccount']()}</Button>
</DialogTrigger>
<DialogContent showOverlay>
<DialogHeader>
<DialogTitle>{t('pages.settings.dangerZone.title')}</DialogTitle>
<DialogDescription>{t('pages.settings.dangerZone.description')}</DialogDescription>
<DialogTitle>{m['pages.settings.dangerZone.title']()}</DialogTitle>
<DialogDescription>{m['pages.settings.dangerZone.description']()}</DialogDescription>
</DialogHeader>
<div className="border-destructive/50 bg-destructive/10 mt-2 flex items-center gap-2 rounded-md border px-3 py-2 text-sm text-red-600 dark:text-red-400">
<AlertTriangle className="h-4 w-4" />
<span>{t('pages.settings.dangerZone.warning')}</span>
<span>{m['pages.settings.dangerZone.warning']()}</span>
</div>
<Form {...form}>
@@ -95,7 +94,7 @@ function DeleteAccountDialog() {
name="confirmText"
render={({ field }) => (
<FormItem>
<FormDescription>{t('pages.settings.dangerZone.confirmation')}</FormDescription>
<FormDescription>{m['pages.settings.dangerZone.confirmation']()}</FormDescription>
<FormControl>
<Input placeholder="DELETE" {...field} />
</FormControl>
@@ -106,8 +105,8 @@ function DeleteAccountDialog() {
<div className="flex justify-end">
<Button type="submit" variant="destructive" disabled={isPending}>
{isPending
? t('pages.settings.dangerZone.deleting')
: t('pages.settings.dangerZone.deleteAccount')}
? m['pages.settings.dangerZone.deleting']()
: m['pages.settings.dangerZone.deleteAccount']()}
</Button>
</div>
</form>
@@ -118,13 +117,11 @@ function DeleteAccountDialog() {
}
export default function DangerPage() {
const t = useTranslations();
return (
<div className="grid gap-6">
<SettingsCard
title={t('pages.settings.dangerZone.title')}
description={t('pages.settings.dangerZone.description')}
title={m['pages.settings.dangerZone.title']()}
description={m['pages.settings.dangerZone.description']()}
>
<DeleteAccountDialog />
</SettingsCard>

View File

@@ -20,31 +20,25 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingsCard } from '@/components/settings/settings-card';
import { Globe, Clock, XIcon, Mail, InfoIcon } from 'lucide-react';
import { useEmailAliases } from '@/hooks/use-email-aliases';
import { getLocale, setLocale } from '@/paraglide/runtime';
import { useState, useEffect, useMemo, memo } from 'react';
import { userSettingsSchema } from '@zero/server/schemas';
import { ScrollArea } from '@/components/ui/scroll-area';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations, useLocale } from 'use-intl';
import { useTRPC } from '@/providers/query-provider';
import { getBrowserTimezone } from '@/lib/timezones';
import { Textarea } from '@/components/ui/textarea';
import { useSettings } from '@/hooks/use-settings';
import { availableLocales } from '@/i18n/config';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useRevalidator } from 'react-router';
// import { useRevalidator } from 'react-router';
import { m } from '@/paraglide/messages';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import * as z from 'zod';
const TimezoneSelect = memo(
({
field,
t,
}: {
field: ControllerRenderProps<z.infer<typeof userSettingsSchema>, 'timezone'>;
t: any;
}) => {
({ field }: { field: ControllerRenderProps<z.infer<typeof userSettingsSchema>, 'timezone'> }) => {
const [open, setOpen] = useState(false);
const [timezoneSearch, setTimezoneSearch] = useState('');
@@ -76,7 +70,7 @@ const TimezoneSelect = memo(
<div className="px-3 py-2">
<input
className="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t('pages.settings.general.selectTimezone')}
placeholder={m['pages.settings.general.selectTimezone']()}
value={timezoneSearch}
onChange={(e) => setTimezoneSearch(e.target.value)}
/>
@@ -85,7 +79,7 @@ const TimezoneSelect = memo(
<div className="p-1">
{filteredTimezones.length === 0 && (
<div className="text-muted-foreground p-2 text-center text-sm">
{t('pages.settings.general.noResultsFound')}
{m['pages.settings.general.noResultsFound']()}
</div>
)}
{filteredTimezones.map((timezone) => (
@@ -117,17 +111,17 @@ TimezoneSelect.displayName = 'TimezoneSelect';
export default function GeneralPage() {
const [isSaving, setIsSaving] = useState(false);
const locale = useLocale();
const t = useTranslations();
const { data } = useSettings();
const locale = getLocale();
const { data, refetch: refetchSettings } = useSettings();
const { data: aliases } = useEmailAliases();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { mutateAsync: saveUserSettings } = useMutation(trpc.settings.save.mutationOptions());
const { mutateAsync: setLocaleCookie } = useMutation(
trpc.cookiePreferences.setLocaleCookie.mutationOptions(),
);
const { revalidate } = useRevalidator();
// const { mutateAsync: setLocaleCookie } = useMutation(
// trpc.cookiePreferences.setLocaleCookie.mutationOptions(),
// );
// const { revalidate } = useRevalidator();
const form = useForm<z.infer<typeof userSettingsSchema>>({
resolver: zodResolver(userSettingsSchema),
@@ -144,6 +138,7 @@ export default function GeneralPage() {
useEffect(() => {
if (data?.settings) {
form.reset(data.settings);
setLocale(data.settings.language as any);
}
}, [form, data?.settings]);
@@ -159,26 +154,18 @@ export default function GeneralPage() {
async function onSubmit(values: z.infer<typeof userSettingsSchema>) {
setIsSaving(true);
const saved = data?.settings ? { ...data.settings } : undefined;
try {
await saveUserSettings(values);
queryClient.setQueryData(trpc.settings.get.queryKey(), (updater) => {
if (!updater) return;
return { settings: { ...updater.settings, ...values } };
});
await saveUserSettings(values);
await refetchSettings();
if (saved?.language !== values.language) {
await setLocaleCookie({ locale: values.language });
const localeName = new Intl.DisplayNames([values.language], { type: 'language' }).of(
values.language,
);
toast.success(t('common.settings.languageChanged', { locale: localeName! }));
await revalidate();
}
toast.success(t('common.settings.saved'));
toast.success(m['common.settings.saved']());
} catch (error) {
console.error('Failed to save settings:', error);
toast.error(t('common.settings.failedToSave'));
toast.error(m['common.settings.failedToSave']());
queryClient.setQueryData(trpc.settings.get.queryKey(), (updater) => {
if (!updater) return;
return saved ? { settings: { ...updater.settings, ...saved } } : updater;
@@ -191,11 +178,11 @@ export default function GeneralPage() {
return (
<div className="grid gap-6">
<SettingsCard
title={t('pages.settings.general.title')}
description={t('pages.settings.general.description')}
title={m['pages.settings.general.title']()}
description={m['pages.settings.general.description']()}
footer={
<Button type="submit" form="general-form" disabled={isSaving}>
{isSaving ? t('common.actions.saving') : t('common.actions.saveChanges')}
{isSaving ? m['common.actions.saving']() : m['common.actions.saveChanges']()}
</Button>
}
>
@@ -207,20 +194,21 @@ export default function GeneralPage() {
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>{t('pages.settings.general.language')}</FormLabel>
<FormLabel>{m['pages.settings.general.language']()}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-36 justify-start">
<Globe className="mr-2 h-4 w-4" />
<SelectValue placeholder={t('pages.settings.general.selectLanguage')} />
<SelectValue placeholder={m['pages.settings.general.selectLanguage']()} />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableLocales.map((locale) => (
{/* TODO: Add available locales */}
{/* {availableLocales.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{locale.name}
</SelectItem>
))}
))} */}
</SelectContent>
</Select>
</FormItem>
@@ -231,8 +219,8 @@ export default function GeneralPage() {
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel>{t('pages.settings.general.timezone')}</FormLabel>
<TimezoneSelect field={field} t={t} />
<FormLabel>{m['pages.settings.general.timezone']()}</FormLabel>
<TimezoneSelect field={field} />
</FormItem>
)}
/>
@@ -243,13 +231,13 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-1">
{t('pages.settings.general.defaultEmailAlias')}{' '}
{m['pages.settings.general.defaultEmailAlias']()}{' '}
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent>
{t('pages.settings.general.defaultEmailDescription')}
{m['pages.settings.general.defaultEmailDescription']()}
</TooltipContent>
</Tooltip>
</FormLabel>
@@ -258,7 +246,7 @@ export default function GeneralPage() {
<SelectTrigger className="w-[300px] justify-start">
<Mail className="mr-2 h-4 w-4" />
<SelectValue
placeholder={t('pages.settings.general.selectDefaultEmail')}
placeholder={m['pages.settings.general.selectDefaultEmail']()}
/>
</SelectTrigger>
</FormControl>
@@ -289,9 +277,9 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem className="flex max-w-xl flex-row items-center justify-between rounded-lg border px-4 py-2">
<div className="space-y-0.5">
<FormLabel>{t('pages.settings.general.zeroSignature')}</FormLabel>
<FormLabel>{m['pages.settings.general.zeroSignature']()}</FormLabel>
<FormDescription>
{t('pages.settings.general.zeroSignatureDescription')}
{m['pages.settings.general.zeroSignatureDescription']()}
</FormDescription>
</div>
<FormControl>
@@ -306,9 +294,9 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem className="flex max-w-xl flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>{t('pages.settings.general.autoRead')}</FormLabel>
<FormLabel>{m['pages.settings.general.autoRead']()}</FormLabel>
<FormDescription>
{t('pages.settings.general.autoReadDescription')}
{m['pages.settings.general.autoReadDescription']()}
</FormDescription>
</div>
<FormControl>

View File

@@ -31,7 +31,7 @@ import { GMAIL_COLORS } from '@/lib/constants';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useForm } from 'react-hook-form';
import { Command } from 'lucide-react';
import { COLORS } from './colors';
@@ -39,7 +39,6 @@ import { useState } from 'react';
import { toast } from 'sonner';
export default function LabelsPage() {
const t = useTranslations();
const { data: labels, isLoading, error, refetch } = useLabels();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState<LabelType | null>(null);
@@ -55,18 +54,18 @@ export default function LabelsPage() {
? updateLabel({ id: editingLabel.id!, name: data.name, color: data.color })
: createLabel({ color: data.color, name: data.name }),
{
loading: t('common.labels.savingLabel'),
success: t('common.labels.saveLabelSuccess'),
error: t('common.labels.failedToSavingLabel'),
loading: m['common.labels.savingLabel'](),
success: m['common.labels.saveLabelSuccess'](),
error: m['common.labels.failedToSavingLabel'](),
},
);
};
const handleDelete = async (id: string) => {
toast.promise(deleteLabel({ id }), {
loading: t('common.labels.deletingLabel'),
success: t('common.labels.deleteLabelSuccess'),
error: t('common.labels.failedToDeleteLabel'),
loading: m['common.labels.deletingLabel'](),
success: m['common.labels.deleteLabelSuccess'](),
error: m['common.labels.failedToDeleteLabel'](),
finally: async () => {
await refetch();
},
@@ -81,14 +80,14 @@ export default function LabelsPage() {
return (
<div className="grid gap-6">
<SettingsCard
title={t('pages.settings.labels.title')}
description={t('pages.settings.labels.description')}
title={m['pages.settings.labels.title']()}
description={m['pages.settings.labels.description']()}
action={
<LabelDialog
trigger={
<Button>
<Plus className="mr-2 h-4 w-4" />
{t('common.mail.createNewLabel')}
{m['common.mail.createNewLabel']()}
</Button>
}
editingLabel={editingLabel}
@@ -114,7 +113,7 @@ export default function LabelsPage() {
<p className="text-muted-foreground py-4 text-center text-sm">{error.message}</p>
) : labels?.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t('common.mail.noLabelsAvailable')}
{m['common.mail.noLabelsAvailable']()}
</p>
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 md:grid-cols-6">
@@ -148,7 +147,7 @@ export default function LabelsPage() {
</Button>
</TooltipTrigger>
<TooltipContent className="dark:bg-panelDark mb-1 bg-white">
{t('common.labels.editLabel')}
{m['common.labels.editLabel']()}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -163,7 +162,7 @@ export default function LabelsPage() {
</Button>
</TooltipTrigger>
<TooltipContent className="dark:bg-panelDark mb-1 bg-white">
{t('common.labels.deleteLabel')}
{m['common.labels.deleteLabel']()}
</TooltipContent>
</Tooltip>
</div>

View File

@@ -18,7 +18,7 @@ import { useSettings } from '@/hooks/use-settings';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { useState, useEffect } from 'react';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useForm } from 'react-hook-form';
import { XIcon } from 'lucide-react';
import { toast } from 'sonner';
@@ -26,7 +26,6 @@ import * as z from 'zod';
export default function PrivacyPage() {
const [isSaving, setIsSaving] = useState(false);
const t = useTranslations();
const { data, refetch } = useSettings();
const trpc = useTRPC();
const { mutateAsync: saveUserSettings } = useMutation(trpc.settings.save.mutationOptions());
@@ -56,8 +55,8 @@ export default function PrivacyPage() {
...values,
}),
{
success: t('common.settings.saved'),
error: t('common.settings.failedToSave'),
success: m['common.settings.saved'](),
error: m['common.settings.failedToSave'](),
finally: async () => {
await refetch();
setIsSaving(false);
@@ -70,11 +69,11 @@ export default function PrivacyPage() {
return (
<div className="grid gap-6">
<SettingsCard
title={t('pages.settings.privacy.title')}
description={t('pages.settings.privacy.description')}
title={m['pages.settings.privacy.title']()}
description={m['pages.settings.privacy.description']()}
footer={
<Button type="submit" form="privacy-form" disabled={isSaving}>
{isSaving ? t('common.actions.saving') : t('common.actions.saveChanges')}
{isSaving ? m['common.actions.saving']() : m['common.actions.saveChanges']()}
</Button>
}
>
@@ -88,10 +87,10 @@ export default function PrivacyPage() {
<FormItem className="bg-popover flex w-full flex-row items-center justify-between rounded-lg border p-4 md:w-auto">
<div className="space-y-0.5">
<FormLabel className="text-base">
{t('pages.settings.privacy.externalImages')}
{m['pages.settings.privacy.externalImages']()}
</FormLabel>
<FormDescription>
{t('pages.settings.privacy.externalImagesDescription')}
{m['pages.settings.privacy.externalImagesDescription']()}
</FormDescription>
</div>
<FormControl className="ml-4">
@@ -108,10 +107,10 @@ export default function PrivacyPage() {
<FormItem className="bg-popover flex w-full flex-col rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
{t('pages.settings.privacy.trustedSenders')}
{m['pages.settings.privacy.trustedSenders']()}
</FormLabel>
<FormDescription>
{t('pages.settings.privacy.trustedSendersDescription')}
{m['pages.settings.privacy.trustedSendersDescription']()}
</FormDescription>
</div>
<ScrollArea className="flex max-h-32 flex-col pr-3">
@@ -131,7 +130,7 @@ export default function PrivacyPage() {
<XIcon className="h-4 w-4 transition hover:opacity-80" />
</button>
</TooltipTrigger>
<TooltipContent>{t('common.actions.remove')}</TooltipContent>
<TooltipContent>{m['common.actions.remove']()}</TooltipContent>
</Tooltip>
</div>
))}

View File

@@ -10,7 +10,7 @@ import { SettingsCard } from '@/components/settings/settings-card';
import { zodResolver } from '@hookform/resolvers/zod';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useForm } from 'react-hook-form';
import { KeyRound } from 'lucide-react';
import { useState } from 'react';
@@ -23,7 +23,6 @@ const formSchema = z.object({
export default function SecurityPage() {
const [isSaving, setIsSaving] = useState(false);
const t = useTranslations();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -46,13 +45,13 @@ export default function SecurityPage() {
return (
<div className="grid gap-6">
<SettingsCard
title={t('pages.settings.security.title')}
description={t('pages.settings.security.description')}
title={m['pages.settings.security.title']()}
description={m['pages.settings.security.description']()}
footer={
<div className="flex gap-4">
<Button variant="destructive">{t('pages.settings.security.deleteAccount')}</Button>
<Button variant="destructive">{m['pages.settings.security.deleteAccount']()}</Button>
<Button type="submit" form="security-form" disabled={isSaving}>
{isSaving ? t('common.actions.saving') : t('common.actions.saveChanges')}
{isSaving ? m['common.actions.saving']() : m['common.actions.saveChanges']()}
</Button>
</div>
}
@@ -67,10 +66,10 @@ export default function SecurityPage() {
<FormItem className="bg-popover flex w-full flex-row items-center justify-between rounded-lg border p-4 md:w-auto">
<div className="space-y-0.5">
<FormLabel className="text-base">
{t('pages.settings.security.twoFactorAuth')}
{m['pages.settings.security.twoFactorAuth']()}
</FormLabel>
<FormDescription>
{t('pages.settings.security.twoFactorAuthDescription')}
{m['pages.settings.security.twoFactorAuthDescription']()}
</FormDescription>
</div>
<FormControl className="ml-4">
@@ -86,10 +85,10 @@ export default function SecurityPage() {
<FormItem className="bg-popover flex w-full flex-row items-center justify-between rounded-lg border p-4 md:w-auto">
<div className="space-y-0.5">
<FormLabel className="text-base">
{t('pages.settings.security.loginNotifications')}
{m['pages.settings.security.loginNotifications']()}
</FormLabel>
<FormDescription>
{t('pages.settings.security.loginNotificationsDescription')}
{m['pages.settings.security.loginNotificationsDescription']()}
</FormDescription>
</div>
<FormControl className="ml-4">

View File

@@ -1,7 +1,6 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import type { MessageKey } from '@/config/navigation';
import { useEffect, useState } from 'react';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
interface HotkeyRecorderProps {
isOpen: boolean;
@@ -16,7 +15,6 @@ export function HotkeyRecorder({
onHotkeyRecorded,
currentKeys,
}: HotkeyRecorderProps) {
const t = useTranslations();
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
const [isRecording, setIsRecording] = useState(false);
@@ -67,15 +65,13 @@ export function HotkeyRecorder({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t('pages.settings.shortcuts.actions.recordHotkey' as MessageKey)}
</DialogTitle>
<DialogTitle>{m['pages.settings.shortcuts.actions.recordHotkey']()}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<div className="text-muted-foreground text-center text-sm">
{isRecording
? t('pages.settings.shortcuts.actions.pressKeys' as MessageKey)
: t('pages.settings.shortcuts.actions.releaseKeys' as MessageKey)}
? m['pages.settings.shortcuts.actions.pressKeys']()
: m['pages.settings.shortcuts.actions.releaseKeys']()}
</div>
<div className="flex gap-2">
{(recordedKeys.length > 0 ? recordedKeys : currentKeys).map((key) => (

View File

@@ -2,17 +2,12 @@ import { keyboardShortcuts, type Shortcut } from '@/config/shortcuts';
import { SettingsCard } from '@/components/settings/settings-card';
import { formatDisplayKeys } from '@/lib/hotkeys/use-hotkey-utils';
import { useShortcutCache } from '@/lib/hotkeys/use-hotkey-utils';
import { useState, type ReactNode, useEffect } from 'react';
import type { MessageKey } from '@/config/navigation';
import { HotkeyRecorder } from './hotkey-recorder';
import { Button } from '@/components/ui/button';
import { useSession } from '@/lib/auth-client';
import { useTranslations } from 'use-intl';
import { toast } from 'sonner';
import { useCategorySettings } from '@/hooks/use-categories';
import { useState, type ReactNode, useEffect } from 'react';
import { useSession } from '@/lib/auth-client';
import { m } from '@/paraglide/messages';
export default function ShortcutsPage() {
const t = useTranslations();
const { data: session } = useSession();
const {
shortcuts,
@@ -24,8 +19,8 @@ export default function ShortcutsPage() {
return (
<div className="grid gap-6">
<SettingsCard
title={t('pages.settings.shortcuts.title')}
description={t('pages.settings.shortcuts.description')}
title={m['pages.settings.shortcuts.title']()}
description={m['pages.settings.shortcuts.description']()}
// footer={
// <div className="flex gap-4">
// <Button
@@ -74,13 +69,19 @@ export default function ShortcutsPage() {
if (shortcut.action in categoryActionIndex && categorySettings.length) {
const idx = categoryActionIndex[shortcut.action];
const cat = categorySettings[idx];
label = cat ? `Show ${cat.name}` : t(`pages.settings.shortcuts.actions.${shortcut.action}` as MessageKey);
label = cat
? `Show ${cat.name}`
: m[`pages.settings.shortcuts.actions.${shortcut.action}`]();
} else {
label = t(`pages.settings.shortcuts.actions.${shortcut.action}` as MessageKey);
label = m[`pages.settings.shortcuts.actions.${shortcut.action}`]();
}
return (
<Shortcut key={`${scope}-${index}`} keys={shortcut.keys} action={shortcut.action}>
<Shortcut
key={`${scope}-${index}`}
keys={shortcut.keys}
action={shortcut.action}
>
{label}
</Shortcut>
);

View File

@@ -1,386 +0,0 @@
//
// // DEPRECATED -
// import {
// Form,
// FormControl,
// FormDescription,
// FormField,
// FormItem,
// FormLabel,
// } from '@/components/ui/form';
// import { SettingsCard } from '@/components/settings/settings-card';
// import { useState, useEffect, useRef } from 'react';
// import { useTranslations } from 'use-intl';
// import { zodResolver } from '@hookform/resolvers/zod';
// import { saveUserSettings } from '@/actions/settings';
// import { useSettings } from '@/hooks/use-settings';
// import { Button } from '@/components/ui/button';
// import { Switch } from '@/components/ui/switch';
// import { Textarea } from '@/components/ui/textarea';
// import { toast } from 'sonner';
// import * as z from 'zod';
// import { useForm } from 'react-hook-form';
// import DOMPurify from 'dompurify';
// import { useImageLoading } from '@/hooks/use-image-loading';
// import Editor from '@/components/create/editor';
// import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
// import { SignaturePreview } from '@/components/mail/signature-preview';
// import { JSONContent } from 'novel';
// const formSchema = z.object({
// signature: z.object({
// enabled: z.boolean(),
// content: z.string().min(1).max(10000),
// includeByDefault: z.boolean(),
// editorType: z.enum(['plain', 'rich']).default('plain'),
// }),
// });
// export default function SignaturesPage() {
// const [isSaving, setIsSaving] = useState(false);
// const [signatureHtml, setSignatureHtml] = useState('');
// const [editorContent, setEditorContent] = useState<JSONContent | undefined>(undefined);
// // Using autofocus instead of a ref for better user experience
// const [autoFocus, setAutoFocus] = useState(false);
// const t = useTranslations();
// const { settings, mutate } = useSettings();
// const form = useForm<z.infer<typeof formSchema>>({
// resolver: zodResolver(formSchema),
// defaultValues: {
// signature: {
// enabled: false,
// content: '--<br><br>Sent via <a href="https://0.email" target="_blank" style="color: #016FFE; text-decoration: none;">0.email</a>',
// includeByDefault: true,
// editorType: 'plain',
// },
// },
// });
// // Helper function to convert HTML to JSONContent more accurately
// const tryParseHtmlToContent = (html: string): JSONContent | undefined => {
// try {
// // Create a temporary div to parse the HTML
// const div = document.createElement('div');
// div.innerHTML = html;
// // Return as a document with proper structure preserving paragraphs
// // This is a basic implementation - for more complex conversions, consider using a
// // dedicated HTML-to-ProseMirror conversion library
// const content: any[] = [];
// // Process each child element to create proper paragraph nodes
// Array.from(div.childNodes).forEach(node => {
// if (node.nodeType === Node.TEXT_NODE) {
// // Plain text nodes
// if (node.textContent?.trim()) {
// content.push({
// type: 'paragraph',
// content: [{ type: 'text', text: node.textContent }]
// });
// }
// } else if (node.nodeName === 'BR') {
// // Line breaks - add an empty paragraph
// content.push({ type: 'paragraph' });
// } else if (node.nodeName === 'P' || node.nodeName === 'DIV') {
// // Paragraph or div elements
// content.push({
// type: 'paragraph',
// content: [{ type: 'text', text: node.textContent || '' }]
// });
// } else {
// // Other elements - try to preserve their content
// content.push({
// type: 'paragraph',
// content: [{ type: 'text', text: node.textContent || '' }]
// });
// }
// });
// // If no content was created, create a default empty paragraph
// if (content.length === 0) {
// content.push({ type: 'paragraph' });
// }
// return {
// type: 'doc',
// content
// };
// } catch (error) {
// console.error('Error parsing HTML to content:', error);
// return undefined;
// }
// };
// // Initialize from settings
// useEffect(() => {
// if (settings?.signature) {
// // Initialize with editorType defaulting to 'plain' for existing users
// form.reset({
// signature: {
// ...settings.signature,
// editorType: settings.signature.editorType || 'plain',
// },
// });
// // Set the raw HTML in the state
// const signatureHtml = settings.signature.content || '--<br><br>Sent via <a href="https://0.email" target="_blank" style="color: #016FFE; text-decoration: none;">0.email</a>';
// setSignatureHtml(signatureHtml);
// // Attempt to parse HTML to JSONContent for the rich editor
// // This is a simple approach - a more robust solution would use a proper HTML to ProseMirror converter
// setEditorContent(tryParseHtmlToContent(signatureHtml));
// } else {
// // For new users with no signature settings yet, set the default content
// const defaultSignature = '--<br><br>Sent via <a href="https://0.email" target="_blank" style="color: #016FFE; text-decoration: none;">0.email</a>';
// setSignatureHtml(defaultSignature);
// setEditorContent(tryParseHtmlToContent(defaultSignature));
// }
// }, [form, settings]);
// async function onSubmit(values: z.infer<typeof formSchema>) {
// setIsSaving(true);
// // Get the content based on editor type
// let contentToSave = signatureHtml;
// // Sanitize HTML before saving
// const sanitizedHtml = DOMPurify.sanitize(contentToSave, {
// ADD_ATTR: ['target'],
// FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
// });
// // Use the sanitized HTML
// const formData = {
// ...values,
// signature: {
// ...values.signature,
// content: sanitizedHtml
// }
// };
// try {
// // We need to merge with the existing settings
// const updatedSettings = {
// ...settings,
// ...formData,
// };
// await saveUserSettings(updatedSettings);
// await mutate(updatedSettings, { revalidate: false });
// toast.success(t('pages.settings.signatures.signatureSaved'));
// } catch (error) {
// console.error('Failed to save signature settings:', error);
// toast.error(t('common.settings.failedToSave'));
// // Revert the optimistic update
// await mutate();
// } finally {
// setIsSaving(false);
// }
// }
// const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// const newValue = e.target.value;
// // Try to clean any malformed HTML
// const cleanedValue = decodeHtmlEntities(newValue);
// setSignatureHtml(cleanedValue);
// // Update the form state
// form.setValue('signature.content', cleanedValue);
// };
// const handleEditorChange = (html: string) => {
// // Process the HTML coming from the rich editor
// setSignatureHtml(html);
// // Update the form state
// form.setValue('signature.content', html);
// };
// const watchSignatureEnabled = form.watch('signature.enabled');
// const watchEditorType = form.watch('signature.editorType');
// // Function to decode HTML entities
// const decodeHtmlEntities = (html: string): string => {
// const textarea = document.createElement('textarea');
// textarea.innerHTML = html;
// return textarea.value;
// };
// // Handle switching between editor types
// useEffect(() => {
// // When switching editor modes
// if (watchEditorType === 'rich') {
// // Clean any double-escaped HTML before loading into rich editor
// const cleanHtml = decodeHtmlEntities(signatureHtml);
// setSignatureHtml(cleanHtml);
// setEditorContent(tryParseHtmlToContent(cleanHtml));
// setAutoFocus(false);
// } else {
// // When switching to plain mode, make sure we're seeing actual HTML, not escaped entities
// const cleanHtml = decodeHtmlEntities(signatureHtml);
// setSignatureHtml(cleanHtml);
// setAutoFocus(true);
// }
// }, [watchEditorType]);
// return (
// <div className="grid gap-6">
// <SettingsCard
// title={t('pages.settings.signatures.title')}
// description={t('pages.settings.signatures.description')}
// footer={
// <Button type="submit" form="signatures-form" disabled={isSaving}>
// {isSaving ? t('common.actions.saving') : t('common.actions.saveChanges')}
// </Button>
// }
// >
// <Form {...form}>
// <form id="signatures-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
// {/* Enable Signature Switch */}
// <FormField
// control={form.control}
// name="signature.enabled"
// render={({ field }) => (
// <FormItem className="bg-popover flex flex-row items-center justify-between rounded-lg border p-4">
// <div className="space-y-0.5">
// <FormLabel className="text-base">
// {t('pages.settings.signatures.enableSignature')}
// </FormLabel>
// <FormDescription>
// {t('pages.settings.signatures.enableSignatureDescription')}
// </FormDescription>
// </div>
// <FormControl>
// <Switch checked={field.value} onCheckedChange={field.onChange} />
// </FormControl>
// </FormItem>
// )}
// />
// {watchSignatureEnabled && (
// <>
// {/* Include by Default Switch */}
// <FormField
// control={form.control}
// name="signature.includeByDefault"
// render={({ field }) => (
// <FormItem className="bg-popover flex flex-row items-center justify-between rounded-lg border p-4">
// <div className="space-y-0.5">
// <FormLabel className="text-base">
// {t('pages.settings.signatures.includeByDefault')}
// </FormLabel>
// <FormDescription>
// {t('pages.settings.signatures.includeByDefaultDescription')}
// </FormDescription>
// </div>
// <FormControl>
// <Switch checked={field.value} onCheckedChange={field.onChange} />
// </FormControl>
// </FormItem>
// )}
// />
// {/* Editor Type Selector - Improved UI */}
// <div className="space-y-2">
// <FormLabel>{t('pages.settings.signatures.editorType')}</FormLabel>
// <div className="flex space-x-2">
// <Button
// type="button"
// variant={watchEditorType === 'plain' ? 'default' : 'outline'}
// onClick={() => form.setValue('signature.editorType', 'plain')}
// className="flex-1"
// >
// <code className="mr-2">&lt;/&gt;</code>
// {t('pages.settings.signatures.plainText')}
// </Button>
// <Button
// type="button"
// variant={watchEditorType === 'rich' ? 'default' : 'outline'}
// onClick={() => form.setValue('signature.editorType', 'rich')}
// className="flex-1"
// >
// <span className="mr-2">
// <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="inline-block">
// <path d="M12 10v4" />
// <line x1="9" y1="6" x2="15" y2="6" />
// <path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-2" />
// <path d="M12 3v7" />
// <path d="M10 16h4" />
// </svg>
// </span>
// {t('pages.settings.signatures.richText')}
// </Button>
// </div>
// <FormDescription>
// {t('pages.settings.signatures.editorTypeDescription')}
// </FormDescription>
// </div>
// {/* Signature Editor - either plain text or rich editor */}
// <div className="space-y-2">
// <label className="text-sm font-medium">{t('pages.settings.signatures.signatureContent')}</label>
// {watchEditorType === 'plain' ? (
// <div className="mt-1">
// <Textarea
// autoFocus={autoFocus}
// className="font-mono text-xs h-[200px]"
// value={signatureHtml}
// onChange={handleTextareaChange}
// placeholder={t('pages.settings.signatures.signatureContentPlaceholder')}
// />
// <p className="text-muted-foreground text-sm mt-2">
// {t('pages.settings.signatures.signatureContentHelp') || "You can use HTML to add formatting, links, and images to your signature."}
// </p>
// <div className="text-xs bg-muted/50 p-2 mt-1 rounded border">
// <strong>Note:</strong> HTML tags are supported for formatting.
// For security reasons, script tags are not allowed. Common useful tags:
// <code className="mx-1 px-1 bg-muted rounded">&lt;a&gt;</code> for links,
// <code className="mx-1 px-1 bg-muted rounded">&lt;br&gt;</code> for line breaks,
// <code className="mx-1 px-1 bg-muted rounded">&lt;b&gt;</code> for bold text.
// </div>
// </div>
// ) : (
// <div className="mt-1 border rounded-md p-2">
// <Editor
// initialValue={editorContent}
// onChange={handleEditorChange}
// placeholder={t('pages.settings.signatures.richTextPlaceholder') || "Format your signature with the rich text editor..."}
// className="w-full"
// />
// <p className="text-muted-foreground text-sm mt-2">
// {t('pages.settings.signatures.richTextDescription')}
// </p>
// </div>
// )}
// </div>
// {/* Signature Preview */}
// {signatureHtml && (
// <div className="space-y-2">
// <h3 className="text-base font-medium">
// {t('pages.settings.signatures.signaturePreview')}
// </h3>
// <div className="border p-4 rounded-md">
// <p className="text-muted-foreground text-sm mb-2">
// {t('pages.settings.signatures.signaturePreviewDescription')}
// </p>
// <div className="border-t pt-2">
// <SignaturePreview
// html={signatureHtml}
// className="w-full min-h-[150px]"
// />
// </div>
// </div>
// </div>
// )}
// </>
// )}
// </form>
// </Form>
// </SettingsCard>
// </div>
// );
// }

View File

@@ -1,5 +1,6 @@
// @ts-expect-error react-dom provides ESM browser build without TS typings yet
import { renderToReadableStream } from 'react-dom/server.browser';
import type { AppLoadContext, EntryContext } from 'react-router';
import { renderToReadableStream } from 'react-dom/server';
import { ServerRouter } from 'react-router';
import { isbot } from 'isbot';

View File

@@ -15,12 +15,11 @@ import { useEffect, type PropsWithChildren } from 'react';
import { AlertCircle, Loader2 } from 'lucide-react';
import { getServerTrpc } from '@/lib/trpc.server';
import { Button } from '@/components/ui/button';
import { getLocale } from '@/paraglide/runtime';
import { siteConfig } from '@/lib/site-config';
import { resolveLocale } from '@/i18n/request';
import { getMessages } from '@/i18n/request';
import { signOut } from '@/lib/auth-client';
import type { Route } from './+types/root';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { ArrowLeft } from 'lucide-react';
import './globals.css';
@@ -37,26 +36,9 @@ export const meta: MetaFunction = () => {
];
};
export async function loader({ request }: Route.ClientLoaderArgs) {
const locale = resolveLocale(request);
const trpc = getServerTrpc(request);
const connectionId = await trpc.connections.getDefault
.query()
.then((res) => res?.id ?? null)
.catch(() => null);
return {
locale: locale ?? 'en',
messages: await getMessages(locale),
connectionId,
};
}
export function Layout({ children }: PropsWithChildren) {
const { locale = 'en', messages, connectionId } = useLoaderData<typeof loader>();
return (
<html lang={locale} suppressHydrationWarning>
<html lang={getLocale()} suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -70,7 +52,7 @@ export function Layout({ children }: PropsWithChildren) {
<Links />
</head>
<body className="antialiased">
<ServerProviders messages={messages} locale={locale} connectionId={connectionId}>
<ServerProviders>
<ClientProviders>{children}</ClientProviders>
</ServerProviders>
<ScrollRestoration />
@@ -150,7 +132,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
function NotFound() {
const navigate = useNavigate();
const t = useTranslations();
return (
<div className="dark:bg-background flex w-full items-center justify-center bg-white text-center">
@@ -165,9 +146,9 @@ function NotFound() {
{/* Message */}
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">
{t('pages.error.notFound.title')}
{m['pages.error.notFound.title']()}
</h2>
<p className="text-muted-foreground">{t('pages.error.notFound.description')}</p>
<p className="text-muted-foreground">{m['pages.error.notFound.description']()}</p>
</div>
{/* Buttons */}
@@ -178,7 +159,7 @@ function NotFound() {
className="text-muted-foreground gap-2"
>
<ArrowLeft className="h-4 w-4" />
{t('pages.error.notFound.goBack')}
{m['pages.error.notFound.goBack']()}
</Button>
</div>
</div>

View File

@@ -11,7 +11,7 @@ import { emailProviders } from '@/lib/constants';
import { authClient } from '@/lib/auth-client';
import { Plus, UserPlus } from 'lucide-react';
import { useLocation } from 'react-router';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { motion } from 'motion/react';
import { Button } from '../ui/button';
import { cn } from '@/lib/utils';
@@ -28,7 +28,6 @@ export const AddConnectionDialog = ({
onOpenChange?: (open: boolean) => void;
}) => {
const { connections, attach } = useBilling();
const t = useTranslations();
const canCreateConnection = useMemo(() => {
if (!connections?.remaining && !connections?.unlimited) return false;
@@ -61,15 +60,15 @@ export const AddConnectionDialog = ({
className={cn('w-full justify-start gap-2', className)}
>
<UserPlus size={16} strokeWidth={2} className="opacity-60" aria-hidden="true" />
<p className="text-[13px] opacity-60">{t('pages.settings.connections.addEmail')}</p>
<p className="text-[13px] opacity-60">{m['pages.settings.connections.addEmail']()}</p>
</Button>
)}
</DialogTrigger>
<DialogContent showOverlay={true}>
<DialogHeader>
<DialogTitle>{t('pages.settings.connections.connectEmail')}</DialogTitle>
<DialogTitle>{m['pages.settings.connections.connectEmail']()}</DialogTitle>
<DialogDescription>
{t('pages.settings.connections.connectEmailDescription')}
{m['pages.settings.connections.connectEmailDescription']()}
</DialogDescription>
</DialogHeader>
{!canCreateConnection && (
@@ -135,7 +134,7 @@ export const AddConnectionDialog = ({
className="h-24 w-full flex-col items-center justify-center gap-2 border-dashed"
>
<Plus className="h-12 w-12" />
<span className="text-xs">{t('pages.settings.connections.moreComingSoon')}</span>
<span className="text-xs">{m['pages.settings.connections.moreComingSoon']()}</span>
</Button>
</motion.div>
</motion.div>

View File

@@ -41,10 +41,10 @@ import {
} from 'react';
import { getMainSearchTerm, parseNaturalLanguageSearch } from '@/lib/utils';
import { DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { navigationConfig, type MessageKey } from '@/config/navigation';
import { useSearchValue } from '@/hooks/use-search-value';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useLocation, useNavigate } from 'react-router';
import { navigationConfig } from '@/config/navigation';
import { Separator } from '@/components/ui/separator';
import { useTRPC } from '@/providers/query-provider';
import { Calendar } from '@/components/ui/calendar';
@@ -54,9 +54,9 @@ import { useLabels } from '@/hooks/use-labels';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { useTranslations } from 'use-intl';
import { format, subDays } from 'date-fns';
import { VisuallyHidden } from 'radix-ui';
import { m } from '@/paraglide/messages';
import { Pencil2 } from '../icons/icons';
import { Button } from '../ui/button';
import { useQueryState } from 'nuqs';
@@ -196,7 +196,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
const [commandInputValue, setCommandInputValue] = useState('');
const navigate = useNavigate();
const { pathname } = useLocation();
const t = useTranslations();
const { data: userLabels = [] } = useLabels();
const trpc = useTRPC();
const { mutateAsync: generateSearchQuery, isPending } = useMutation(
@@ -716,7 +716,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
group.items.forEach((navItem) => {
if (navItem.disabled) return;
const item: CommandItem = {
title: t(navItem.title as MessageKey),
title: navItem.title,
icon: navItem.icon,
url: navItem.url,
shortcut: navItem.shortcut,
@@ -760,7 +760,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
let groupTitle = groupKey;
try {
const translationKey = `common.commandPalette.groups.${groupKey}` as any;
groupTitle = t(translationKey) || groupKey;
groupTitle = (m as any)[translationKey]() || groupKey;
} catch {}
result.push({
@@ -771,7 +771,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
});
return result;
}, [pathname, t, setIsComposeOpen, quickFilterOptions]);
}, [pathname, setIsComposeOpen, quickFilterOptions]);
const hasMatchingCommands = useMemo(() => {
if (!commandInputValue.trim()) return true;
@@ -1888,8 +1888,8 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
}}
>
<VisuallyHidden.VisuallyHidden>
<DialogTitle>{t('common.commandPalette.title')}</DialogTitle>
<DialogDescription>{t('common.commandPalette.description')}</DialogDescription>
<DialogTitle>{m['common.commandPalette.title']()}</DialogTitle>
<DialogDescription>{m['common.commandPalette.description']()}</DialogDescription>
</VisuallyHidden.VisuallyHidden>
{renderView()}
</CommandDialog>

View File

@@ -17,7 +17,7 @@ import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { useLabels } from '@/hooks/use-labels';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { Trash } from '../icons/icons';
import { Button } from '../ui/button';
import { toast } from 'sonner';
@@ -39,14 +39,14 @@ interface LabelSidebarContextMenuProps {
export function LabelSidebarContextMenu({ children, labelId, hide }: LabelSidebarContextMenuProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const t = useTranslations();
const trpc = useTRPC();
const { mutateAsync: deleteLabel } = useMutation(trpc.labels.delete.mutationOptions());
const { refetch } = useLabels();
const handleDelete = () => {
toast.promise(deleteLabel({ id: labelId }), {
success: t('common.labels.deleteLabelSuccess'),
success: m['common.labels.deleteLabelSuccess'](),
error: 'Error deleting label',
finally: () => {
refetch();
@@ -74,7 +74,7 @@ export function LabelSidebarContextMenu({ children, labelId, hide }: LabelSideba
className="hover:bg-[#FDE4E9] dark:hover:bg-[#411D23] [&_svg]:size-3.5"
>
<Trash className="fill-[#F43F5E]" />
<span>{t('common.labels.deleteLabel')}</span>
<span>{m['common.labels.deleteLabel']()}</span>
</Button>
</ContextMenuItem>
</ContextMenuContent>
@@ -83,17 +83,19 @@ export function LabelSidebarContextMenu({ children, labelId, hide }: LabelSideba
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent showOverlay={true}>
<DialogHeader>
<DialogTitle>{t('common.labels.deleteLabelConfirm')}</DialogTitle>
<DialogTitle>{m['common.labels.deleteLabelConfirm']()}</DialogTitle>
<DialogDescription>
{t('common.labels.deleteLabelConfirmDescription')}
{m['common.labels.deleteLabelConfirmDescription']()}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">{t('common.labels.deleteLabelConfirmCancel')}</Button>
<Button variant="outline">{m['common.labels.deleteLabelConfirmCancel']()}</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={handleDelete}>{t('common.labels.deleteLabelConfirmDelete')}</Button>
<Button onClick={handleDelete}>
{m['common.labels.deleteLabelConfirmDelete']()}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -31,8 +31,8 @@ import { useMemo, type ReactNode } from 'react';
import { useLabels } from '@/hooks/use-labels';
import { FOLDERS, LABELS } from '@/lib/utils';
import { useMail } from '../mail/use-mail';
import { useTranslations } from 'use-intl';
import { Checkbox } from '../ui/checkbox';
import { m } from '@/paraglide/messages';
import { useParams } from 'react-router';
import { useQueryState } from 'nuqs';
import { toast } from 'sonner';
@@ -137,7 +137,7 @@ export function ThreadContextMenu({
const [{ isLoading, isFetching }] = useThreads();
const currentFolder = folder ?? '';
const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE;
const t = useTranslations();
const [, setMode] = useQueryState('mode');
const [, setThreadId] = useQueryState('threadId');
const { data: threadData } = useThread(threadId);
@@ -166,7 +166,7 @@ export function ThreadContextMenu({
}, [threadData]);
const noopAction = () => async () => {
toast.info(t('common.actions.featureNotImplemented'));
toast.info(m['common.actions.featureNotImplemented']());
};
const { optimisticMoveThreadsTo } = useOptimisticActions();
@@ -193,7 +193,7 @@ export function ThreadContextMenu({
}
} catch (error) {
console.error(`Error moving ${threadId ? 'email' : 'thread'}:`, error);
toast.error(t('common.actions.failedToMove'));
toast.error(m['common.actions.failedToMove']());
}
};
@@ -269,21 +269,21 @@ export function ThreadContextMenu({
const primaryActions: EmailAction[] = [
{
id: 'reply',
label: t('common.mail.reply'),
label: m['common.mail.reply'](),
icon: <Reply className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleThreadReply,
disabled: false,
},
{
id: 'reply-all',
label: t('common.mail.replyAll'),
label: m['common.mail.replyAll'](),
icon: <ReplyAll className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleThreadReplyAll,
disabled: false,
},
{
id: 'forward',
label: t('common.mail.forward'),
label: m['common.mail.forward'](),
icon: <Forward className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleThreadForward,
disabled: false,
@@ -313,14 +313,14 @@ export function ThreadContextMenu({
return [
{
id: 'move-to-inbox',
label: t('common.mail.moveToInbox'),
label: m['common.mail.moveToInbox'](),
icon: <Inbox className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.SPAM, LABELS.INBOX),
disabled: false,
},
{
id: 'move-to-bin',
label: t('common.mail.moveToBin'),
label: m['common.mail.moveToBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.SPAM, LABELS.TRASH),
disabled: false,
@@ -332,14 +332,14 @@ export function ThreadContextMenu({
return [
{
id: 'restore-from-bin',
label: t('common.mail.restoreFromBin'),
label: m['common.mail.restoreFromBin'](),
icon: <Inbox className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.TRASH, LABELS.INBOX),
disabled: false,
},
{
id: 'delete-from-bin',
label: t('common.mail.deleteFromBin'),
label: m['common.mail.deleteFromBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleDelete(),
disabled: true,
@@ -351,14 +351,14 @@ export function ThreadContextMenu({
return [
{
id: 'move-to-inbox',
label: t('common.mail.unarchive'),
label: m['common.mail.unarchive'](),
icon: <Inbox className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove('', LABELS.INBOX),
disabled: false,
},
{
id: 'move-to-bin',
label: t('common.mail.moveToBin'),
label: m['common.mail.moveToBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove('', LABELS.TRASH),
disabled: false,
@@ -370,14 +370,14 @@ export function ThreadContextMenu({
return [
{
id: 'archive',
label: t('common.mail.archive'),
label: m['common.mail.archive'](),
icon: <Archive className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.SENT, ''),
disabled: false,
},
{
id: 'move-to-bin',
label: t('common.mail.moveToBin'),
label: m['common.mail.moveToBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.SENT, LABELS.TRASH),
disabled: false,
@@ -388,21 +388,21 @@ export function ThreadContextMenu({
return [
{
id: 'archive',
label: t('common.mail.archive'),
label: m['common.mail.archive'](),
icon: <Archive className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.INBOX, ''),
disabled: false,
},
{
id: 'move-to-spam',
label: t('common.mail.moveToSpam'),
label: m['common.mail.moveToSpam'](),
icon: <ArchiveX className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.INBOX, LABELS.SPAM),
disabled: !isInbox,
},
{
id: 'move-to-bin',
label: t('common.mail.moveToBin'),
label: m['common.mail.moveToBin'](),
icon: <Trash className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleMove(LABELS.INBOX, LABELS.TRASH),
disabled: false,
@@ -413,7 +413,7 @@ export function ThreadContextMenu({
const otherActions: EmailAction[] = [
{
id: 'toggle-read',
label: isUnread ? t('common.mail.markAsRead') : t('common.mail.markAsUnread'),
label: isUnread ? m['common.mail.markAsRead']() : m['common.mail.markAsUnread'](),
icon: !isUnread ? (
<Mail className="mr-2.5 h-4 w-4 fill-[#9D9D9D] dark:fill-[#9D9D9D]" />
) : (
@@ -424,13 +424,15 @@ export function ThreadContextMenu({
},
{
id: 'toggle-important',
label: isImportant ? t('common.mail.removeFromImportant') : t('common.mail.markAsImportant'),
label: isImportant
? m['common.mail.removeFromImportant']()
: m['common.mail.markAsImportant'](),
icon: <ExclamationCircle className="mr-2.5 h-4 w-4 opacity-60" />,
action: handleToggleImportant,
},
{
id: 'favorite',
label: isStarred ? t('common.mail.removeFavorite') : t('common.mail.addFavorite'),
label: isStarred ? m['common.mail.removeFavorite']() : m['common.mail.addFavorite'](),
icon: isStarred ? (
<StarOff className="mr-2.5 h-4 w-4 opacity-60" />
) : (
@@ -471,7 +473,7 @@ export function ThreadContextMenu({
<ContextMenuSub>
<ContextMenuSubTrigger className="font-normal">
<Tag className="mr-2.5 h-4 w-4 opacity-60" />
{t('common.mail.labels')}
{m['common.mail.labels']()}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="dark:bg-panelDark max-h-[520px] w-48 overflow-y-auto bg-white">
<LabelsList threadId={threadId} bulkSelected={mail.bulkSelected} />

View File

@@ -12,7 +12,7 @@ import { useSession } from '@/lib/auth-client';
import { serializeFiles } from '@/lib/schemas';
import { useDraft } from '@/hooks/use-drafts';
import { useNavigate } from 'react-router';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useQueryState } from 'nuqs';
import { X } from '../icons/icons';
import posthog from 'posthog-js';
@@ -62,7 +62,7 @@ export function CreateEmail({
isLoading: isDraftLoading,
error: draftError,
} = useDraft(draftId ?? propDraftId ?? null);
const t = useTranslations();
const [, setIsDraftFailed] = useState(false);
const trpc = useTRPC();
const { mutateAsync: sendEmail } = useMutation(trpc.mail.send.mutationOptions());
@@ -125,7 +125,7 @@ export function CreateEmail({
posthog.capture('Create Email Sent');
}
toast.success(t('pages.createEmail.emailSentSuccessfully'));
toast.success(m['pages.createEmail.emailSentSuccessfully']());
};
useEffect(() => {

View File

@@ -51,8 +51,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EditorView } from 'prosemirror-view';
import { Markdown } from 'tiptap-markdown';
import { useTranslations } from 'use-intl';
import { Slice } from 'prosemirror-model';
import { m } from '@/paraglide/messages';
import { useState } from 'react';
import React from 'react';
@@ -131,7 +131,7 @@ interface MenuBarProps {
const MenuBar = () => {
const { editor } = useCurrentEditor();
const t = useTranslations();
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
@@ -193,7 +193,7 @@ const MenuBar = () => {
<Bold className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('pages.createEmail.editor.menuBar.bold')}</TooltipContent>
<TooltipContent>{m.pages.createEmail.editor.menuBar.bold()}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -209,7 +209,7 @@ const MenuBar = () => {
<Italic className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('pages.createEmail.editor.menuBar.italic')}</TooltipContent>
<TooltipContent>{m.pages.createEmail.editor.menuBar.italic()}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -226,7 +226,7 @@ const MenuBar = () => {
</Button>
</TooltipTrigger>
<TooltipContent>
{t('pages.createEmail.editor.menuBar.strikethrough')}
{m.pages.createEmail.editor.menuBar.strikethrough()}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -242,7 +242,7 @@ const MenuBar = () => {
<Underline className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('pages.createEmail.editor.menuBar.underline')}</TooltipContent>
<TooltipContent>{m.pages.createEmail.editor.menuBar.underline()}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -257,7 +257,7 @@ const MenuBar = () => {
<LinkIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('pages.createEmail.editor.menuBar.link')}</TooltipContent>
<TooltipContent>{m.pages.createEmail.editor.menuBar.link()}</TooltipContent>
</Tooltip>
</div>
@@ -277,7 +277,7 @@ const MenuBar = () => {
<List className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('pages.createEmail.editor.menuBar.bulletList')}</TooltipContent>
<TooltipContent>{m.pages.createEmail.editor.menuBar.bulletList()}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -292,7 +292,7 @@ const MenuBar = () => {
<ListOrdered className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('pages.createEmail.editor.menuBar.orderedList')}</TooltipContent>
<TooltipContent>{m.pages.createEmail.editor.menuBar.orderedList()}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -302,8 +302,8 @@ const MenuBar = () => {
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('pages.createEmail.addLink')}</DialogTitle>
<DialogDescription>{t('pages.createEmail.addUrlToCreateALink')}</DialogDescription>
<DialogTitle>{m.pages.createEmail.addLink()}</DialogTitle>
<DialogDescription>{m.pages.createEmail.addUrlToCreateALink()}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
@@ -320,10 +320,10 @@ const MenuBar = () => {
</div>
<DialogFooter className="flex justify-between sm:justify-between">
<Button variant="outline" onClick={handleRemoveLink} type="button">
{t('common.actions.cancel')}
{m.common.actions.cancel()}
</Button>
<Button onClick={handleSaveLink} type="button">
{t('common.actions.save')}
{m.common.actions.save()}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,10 +1,10 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Settings, Image, FileImage, Zap } from 'lucide-react';
import type { ImageQuality } from '@/lib/image-compression';
import { useTranslations } from 'use-intl';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { m } from '@/paraglide/messages';
interface ImageCompressionSettingsProps {
quality: ImageQuality;
@@ -35,23 +35,24 @@ export function ImageCompressionSettings({
onQualityChange,
className,
}: ImageCompressionSettingsProps) {
const t = useTranslations();
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<CardTitle className="text-sm font-medium">
{t('pages.createEmail.imageCompression.title')}
{m['pages.createEmail.imageCompression.title']()}
</CardTitle>
</div>
<CardDescription className="text-xs">
{t('pages.createEmail.imageCompression.description')}
{m['pages.createEmail.imageCompression.description']()}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<RadioGroup value={quality} onValueChange={(value) => onQualityChange(value as ImageQuality)}>
<RadioGroup
value={quality}
onValueChange={(value) => onQualityChange(value as ImageQuality)}
>
<div className="space-y-3">
{qualityOptions.map((option) => {
const Icon = option.icon;
@@ -61,14 +62,11 @@ export function ImageCompressionSettings({
<div className="flex items-center gap-2">
<Icon className={`h-4 w-4 ${option.color}`} />
<div className="flex flex-col">
<Label
htmlFor={option.value}
className="text-sm font-medium cursor-pointer"
>
{t(`pages.createEmail.imageCompression.${option.value}.label`)}
<Label htmlFor={option.value} className="cursor-pointer text-sm font-medium">
{m[`pages.createEmail.imageCompression.${option.value}.label`]()}
</Label>
<span className="text-xs text-muted-foreground">
{t(`pages.createEmail.imageCompression.${option.value}.description`)}
<span className="text-muted-foreground text-xs">
{m[`pages.createEmail.imageCompression.${option.value}.description`]()}
</span>
</div>
</div>
@@ -80,4 +78,4 @@ export function ImageCompressionSettings({
</CardContent>
</Card>
);
}
}

View File

@@ -22,7 +22,7 @@ import { Label } from '@/components/ui/label';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Command } from 'lucide-react';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
interface LabelDialogProps {
trigger?: React.ReactNode;
@@ -45,7 +45,7 @@ export function LabelDialog({
const isControlled = open !== undefined;
const dialogOpen = isControlled ? open : isOpen;
const setDialogOpen = isControlled ? onOpenChange! : setIsOpen;
const t = useTranslations();
const form = useForm<LabelType>({
defaultValues: {
name: '',
@@ -94,7 +94,7 @@ export function LabelDialog({
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent showOverlay={true}>
<DialogHeader>
<DialogTitle>{editingLabel ? t('common.labels.editLabel') : t('common.mail.createNewLabel')}</DialogTitle>
<DialogTitle>{editingLabel ? m['common.labels.editLabel']() : m['common.mail.createNewLabel']()}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
@@ -113,7 +113,7 @@ export function LabelDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.labels.labelName')}</FormLabel>
<FormLabel>{m['common.labels.labelName']()}</FormLabel>
<FormControl>
<Input placeholder="Enter label name" {...field} autoFocus />
</FormControl>
@@ -122,7 +122,7 @@ export function LabelDialog({
)}
/>
<div className="space-y-2">
<Label>{t('common.labels.color')}</Label>
<Label>{m['common.labels.color']()}</Label>
<div className="w-full">
<div className="flex flex-wrap gap-2">
{LABEL_COLORS.map((color, index) => (
@@ -150,10 +150,10 @@ export function LabelDialog({
</div>
<div className="flex justify-end gap-2">
<Button className="h-8" type="button" variant="outline" onClick={handleClose}>
{t('common.actions.cancel')}
{m['common.actions.cancel']()}
</Button>
<Button className="h-8 [&_svg]:size-4" type="submit">
{editingLabel ? t('common.actions.saveChanges') : t('common.labels.createLabel')}
{editingLabel ? m['common.actions.saveChanges']() : m['common.labels.createLabel']()}
<div className="flex h-5 items-center justify-center gap-1 rounded-sm bg-white/10 px-1 dark:bg-black/10">
<Command className="h-3 w-3 text-white dark:text-[#929292]" />
<CurvedArrow className="mt-1.5 h-3.5 w-3.5 fill-white dark:fill-[#929292]" />

View File

@@ -31,6 +31,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { cn, getEmailLogo, formatDate, formatTime, shouldShowSeparateTime } from '@/lib/utils';
import { Dialog, DialogTitle, DialogHeader, DialogContent } from '../ui/dialog';
import { memo, useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -38,7 +39,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
import type { Sender, ParsedMessage, Attachment } from '@/types';
import { useActiveConnection } from '@/hooks/use-connections';
import { cn, getEmailLogo, formatDate, formatTime, shouldShowSeparateTime } from '@/lib/utils';
import { useBrainState } from '../../hooks/use-summary';
import { useTRPC } from '@/providers/query-provider';
import { useThreadLabels } from '@/hooks/use-labels';
@@ -48,15 +48,14 @@ import { useSummary } from '@/hooks/use-summary';
import { TextShimmer } from '../ui/text-shimmer';
import { RenderLabels } from './render-labels';
import { MailIframe } from './mail-iframe';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useParams } from 'react-router';
import { FileText } from 'lucide-react';
import { Button } from '../ui/button';
import { useQueryState } from 'nuqs';
import { Badge } from '../ui/badge';
import { toast } from 'sonner';
import { format } from 'date-fns';
import { toast } from 'sonner';
// HTML escaping function to prevent XSS attacks
function escapeHtml(text: string): string {
@@ -297,7 +296,6 @@ type Props = {
};
const MailDisplayLabels = ({ labels }: { labels: string[] }) => {
const t = useTranslations();
const visibleLabels = labels.filter(
(label) => !['unread', 'inbox'].includes(label.toLowerCase()),
);
@@ -317,42 +315,42 @@ const MailDisplayLabels = ({ labels }: { labels: string[] }) => {
case 'important':
icon = <Lightning className="h-3.5 w-3.5 fill-white" />;
bgColor = 'bg-[#F59E0D]';
labelText = t('common.mailCategories.important');
labelText = m['common.mailCategories.important']();
break;
case 'promotions':
icon = <Tag className="h-3.5 w-3.5 fill-white" />;
bgColor = 'bg-[#F43F5E]';
labelText = t('common.mailCategories.promotions');
labelText = m['common.mailCategories.promotions']();
break;
case 'personal':
icon = <User className="h-3.5 w-3.5 fill-white" />;
bgColor = 'bg-[#39AE4A]';
labelText = t('common.mailCategories.personal');
labelText = m['common.mailCategories.personal']();
break;
case 'updates':
icon = <Bell className="h-3.5 w-3.5 fill-white" />;
bgColor = 'bg-[#8B5CF6]';
labelText = t('common.mailCategories.updates');
labelText = m['common.mailCategories.updates']();
break;
case 'work':
icon = <Briefcase className="h-3.5 w-3.5 text-white" />;
bgColor = '';
labelText = t('common.mailCategories.work');
labelText = m['common.mailCategories.work']();
break;
case 'forums':
icon = <Users className="h-3.5 w-3.5 text-white" />;
bgColor = 'bg-blue-600';
labelText = t('common.mailCategories.forums');
labelText = m['common.mailCategories.forums']();
break;
case 'notes':
icon = <StickyNote className="h-3.5 w-3.5 text-white" />;
bgColor = 'bg-amber-500';
labelText = t('common.mailCategories.notes');
labelText = m['common.mailCategories.notes']();
break;
case 'starred':
icon = <Star className="h-3.5 w-3.5 fill-white text-white" />;
bgColor = 'bg-yellow-500';
labelText = t('common.mailCategories.starred');
labelText = m['common.mailCategories.starred']();
break;
default:
return null;
@@ -790,7 +788,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
const [openDetailsPopover, setOpenDetailsPopover] = useState<boolean>(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const collapseTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const t = useTranslations();
const [activeReplyId, setActiveReplyId] = useQueryState('activeReplyId');
const { labels: threadLabels } = useThreadLabels(
emailData.tags ? emailData.tags.map((l) => l.id) : [],
@@ -891,12 +889,10 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
// Handle email copy of senders
const handleCopySenderEmail = useCallback(async (personEmail: string) => {
if (!personEmail) return;
if(!personEmail) return ;
await navigator.clipboard.writeText(personEmail || '');
toast.success('Email copied to clipboard');
await navigator.clipboard.writeText(personEmail || '');
toast.success('Email copied to clipboard');
}, []);
// email printing
@@ -1298,8 +1294,8 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
</div>
</div>
</PopoverTrigger>
<PopoverContent className="text-sm min-w-fit">
<div className='flex items-center gap-2'>
<PopoverContent className="min-w-fit text-sm">
<div className="flex items-center gap-2">
<Avatar className="h-12 w-12">
<AvatarImage src={getEmailLogo(person.email)} className="rounded-full" />
<AvatarFallback className="bg-offsetLight rounded-full text-sm font-bold dark:bg-[#373737]">
@@ -1307,14 +1303,14 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
</AvatarFallback>
</Avatar>
<div>
<p className='font-medium'>{person.name || 'Unknown'}</p>
<div className="flex gap-2 items-center group">
<p className="font-medium">{person.name || 'Unknown'}</p>
<div className="group flex items-center gap-2">
<p>{person.email || 'No email'}</p>
<span className="opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<span className="opacity-0 transition-opacity duration-150 group-hover:opacity-100">
<CopyIcon
size={14}
className="cursor-pointer"
onClick={() => handleCopySenderEmail(person.email)}
size={14}
className="cursor-pointer"
onClick={() => handleCopySenderEmail(person.email)}
/>
</span>
</div>
@@ -1323,7 +1319,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
</PopoverContent>
</Popover>
),
[]
[],
);
const people = useMemo(() => {
@@ -1483,12 +1479,12 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
ref={triggerRef}
>
<p className="text-muted-foreground text-xs underline dark:text-[#8C8C8C]">
{t('common.mailDisplay.details')}
{m['common.mailDisplay.details']()}
</p>
</button>
</PopoverTrigger>
<PopoverContent
className="flex dark:bg-panelDark w-[420px] rounded-lg border p-4 text-left shadow-lg overflow-auto"
className="dark:bg-panelDark flex w-[420px] overflow-auto rounded-lg border p-4 text-left shadow-lg"
onBlur={(e) => {
if (!triggerRef.current?.contains(e.relatedTarget)) {
setOpenDetailsPopover(false);
@@ -1499,7 +1495,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
<div className="space-y-1 text-sm">
<div className="flex">
<span className="w-24 text-end text-gray-500">
{t('common.mailDisplay.from')}:
{m['common.mailDisplay.from']()}:
</span>
<div className="ml-3">
<span className="text-muted-foreground text-nowrap pr-1 font-bold">
@@ -1514,7 +1510,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
</div>
<div className="flex">
<span className="w-24 text-nowrap text-end text-gray-500">
{t('common.mailDisplay.to')}:
{m['common.mailDisplay.to']()}:
</span>
<span className="text-muted-foreground ml-3 text-nowrap">
{emailData?.to
@@ -1525,7 +1521,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
{emailData?.replyTo && emailData.replyTo.length > 0 && (
<div className="flex">
<span className="w-24 text-nowrap text-end text-gray-500">
{t('common.mailDisplay.replyTo')}:
{m['common.mailDisplay.replyTo']()}:
</span>
<span className="text-muted-foreground ml-3 text-nowrap">
{cleanEmailDisplay(emailData?.replyTo)}
@@ -1535,7 +1531,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
{emailData?.cc && emailData.cc.length > 0 && (
<div className="flex">
<span className="shrink-0text-nowrap w-24 text-end text-gray-500">
{t('common.mailDisplay.cc')}:
{m['common.mailDisplay.cc']()}:
</span>
<span className="text-muted-foreground ml-3 text-nowrap">
{emailData?.cc
@@ -1547,7 +1543,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
{emailData?.bcc && emailData.bcc.length > 0 && (
<div className="flex">
<span className="w-24 text-end text-gray-500">
{t('common.mailDisplay.bcc')}:
{m['common.mailDisplay.bcc']()}:
</span>
<span className="text-muted-foreground ml-3 text-nowrap">
{emailData?.bcc
@@ -1558,7 +1554,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
)}
<div className="flex">
<span className="w-24 text-end text-gray-500">
{t('common.mailDisplay.date')}:
{m['common.mailDisplay.date']()}:
</span>
<span className="text-muted-foreground ml-3 text-nowrap">
{emailData?.receivedOn &&
@@ -1569,7 +1565,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
</div>
<div className="flex">
<span className="w-24 text-end text-gray-500">
{t('common.mailDisplay.mailedBy')}:
{m['common.mailDisplay.mailedBy']()}:
</span>
<span className="text-muted-foreground ml-3 text-nowrap">
{cleanEmailDisplay(emailData?.sender?.email)}
@@ -1577,7 +1573,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
</div>
<div className="flex">
<span className="w-24 text-end text-gray-500">
{t('common.mailDisplay.signedBy')}:
{m['common.mailDisplay.signedBy']()}:
</span>
<span className="text-muted-foreground ml-3 text-nowrap">
{cleanEmailDisplay(emailData?.sender?.email)}
@@ -1586,11 +1582,11 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
{emailData.tls && (
<div className="flex items-center">
<span className="w-24 text-end text-gray-500">
{t('common.mailDisplay.security')}:
{m['common.mailDisplay.security']()}:
</span>
<div className="text-muted-foreground ml-3 flex items-center gap-1">
<Lock className="h-4 w-4 text-green-600" />{' '}
{t('common.mailDisplay.standardEncryption')}
{m['common.mailDisplay.standardEncryption']()}
</div>
</div>
)}
@@ -1600,7 +1596,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
</div>
<div className="flex items-center justify-center">
<div className="text-muted-foreground mr-2 text-sm font-medium dark:text-[#8C8C8C] flex flex-col items-end">
<div className="text-muted-foreground mr-2 flex flex-col items-end text-sm font-medium dark:text-[#8C8C8C]">
<time>
{emailData?.receivedOn ? formatDate(emailData.receivedOn) : ''}
</time>
@@ -1610,7 +1606,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
</time>
)}
</div>
{/* options menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1633,7 +1629,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
}}
>
<Printer className="fill-iconLight dark:fill-iconDark mr-2 h-4 w-4" />
{t('common.mailDisplay.print')}
{m['common.mailDisplay.print']()}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!emailData.attachments?.length}
@@ -1656,7 +1652,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
<div className="flex justify-between">
<div className="flex gap-1">
<p className="text-muted-foreground text-sm font-medium dark:text-[#8C8C8C]">
{t('common.mailDisplay.to')}:{' '}
{m['common.mailDisplay.to']()}:{' '}
{(() => {
// Combine to and cc recipients
const allRecipients = [
@@ -1813,7 +1809,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
setActiveReplyId(emailData.id);
}}
icon={<Reply className="fill-muted-foreground dark:fill-[#9B9B9B]" />}
text={t('common.mail.reply')}
text={m['common.mail.reply']()}
shortcut={isLastEmail ? 'r' : undefined}
/>
<ActionButton
@@ -1824,7 +1820,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
setActiveReplyId(emailData.id);
}}
icon={<ReplyAll className="fill-muted-foreground dark:fill-[#9B9B9B]" />}
text={t('common.mail.replyAll')}
text={m['common.mail.replyAll']()}
shortcut={isLastEmail ? 'a' : undefined}
/>
<ActionButton
@@ -1835,7 +1831,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
setActiveReplyId(emailData.id);
}}
icon={<Forward className="fill-muted-foreground dark:fill-[#9B9B9B]" />}
text={t('common.mail.forward')}
text={m['common.mail.forward']()}
shortcut={isLastEmail ? 'f' : undefined}
/>
</div>

View File

@@ -6,10 +6,10 @@ import { fixNonReadableColors } from '@/lib/email-utils';
import { useTRPC } from '@/providers/query-provider';
import { getBrowserTimezone } from '@/lib/timezones';
import { useSettings } from '@/hooks/use-settings';
import { useTranslations } from 'use-intl';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { m } from '@/paraglide/messages';
export function MailIframe({ html, senderEmail }: { html: string; senderEmail: string }) {
const { data, refetch } = useSettings();
@@ -67,7 +67,7 @@ export function MailIframe({ html, senderEmail }: { html: string; senderEmail: s
refetchOnMount: false, // Don't refetch on mount if data exists
});
const t = useTranslations();
const calculateAndSetHeight = useCallback(() => {
if (!iframeRef.current?.contentWindow?.document.body) return;
@@ -143,17 +143,17 @@ export function MailIframe({ html, senderEmail }: { html: string; senderEmail: s
<>
{cspViolation && !isTrustedSender && !data?.settings?.externalImages && (
<div className="flex items-center justify-start bg-amber-600/20 px-2 py-1 text-sm text-amber-600">
<p>{t('common.actions.hiddenImagesWarning')}</p>
<p>{m['common.actions.hiddenImagesWarning']()}</p>
<button
onClick={() => setTemporaryImagesEnabled(!temporaryImagesEnabled)}
className="ml-2 cursor-pointer underline"
>
{temporaryImagesEnabled
? t('common.actions.disableImages')
: t('common.actions.showImages')}
? m['common.actions.disableImages']()
: m['common.actions.showImages']()}
</button>
<button onClick={() => void trustSender()} className="ml-2 cursor-pointer underline">
{t('common.actions.trustSender')}
{m['common.actions.trustSender']()}
</button>
</div>
)}

View File

@@ -14,7 +14,6 @@ import {
Trash,
PencilCompose,
} from '../icons/icons';
import { StickyNote } from 'lucide-react';
import {
memo,
useCallback,
@@ -43,21 +42,22 @@ import { useTRPC } from '@/providers/query-provider';
import { useThreadLabels } from '@/hooks/use-labels';
import { template } from '@/lib/email-utils.client';
import { useSettings } from '@/hooks/use-settings';
import { useThreadNotes } from '@/hooks/use-notes';
import { useKeyState } from '@/hooks/use-hot-key';
import { VList, type VListHandle } from 'virtua';
import { RenderLabels } from './render-labels';
import { Badge } from '@/components/ui/badge';
import { useDraft } from '@/hooks/use-drafts';
import { Check, Star } from 'lucide-react';
import { useTranslations } from 'use-intl';
import { Skeleton } from '../ui/skeleton';
import { StickyNote } from 'lucide-react';
import { m } from '@/paraglide/messages';
import { useParams } from 'react-router';
import { useTheme } from 'next-themes';
import { Button } from '../ui/button';
import { useQueryState } from 'nuqs';
import { Categories } from './mail';
import { useAtom } from 'jotai';
import { useThreadNotes } from '@/hooks/use-notes';
const Thread = memo(
function Thread({
@@ -67,7 +67,6 @@ const Thread = memo(
index,
}: ThreadProps & { index?: number }) {
const [searchValue, setSearchValue] = useSearchValue();
const t = useTranslations();
const { folder } = useParams<{ folder: string }>();
const [{}, threads] = useThreads();
const [threadId] = useQueryState('threadId');
@@ -346,8 +345,8 @@ const Thread = memo(
className="mb-1 bg-white dark:bg-[#1A1A1A]"
>
{displayStarred
? t('common.threadDisplay.unstar')
: t('common.threadDisplay.star')}
? m['common.threadDisplay.unstar']()
: m['common.threadDisplay.star']()}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -370,7 +369,7 @@ const Thread = memo(
side={index === 0 ? 'bottom' : 'top'}
className="dark:bg-panelDark mb-1 bg-white"
>
{t('common.mail.toggleImportant')}
{m['common.mail.toggleImportant']()}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -391,7 +390,7 @@ const Thread = memo(
side={index === 0 ? 'bottom' : 'top'}
className="dark:bg-panelDark mb-1 bg-white"
>
{t('common.threadDisplay.archive')}
{m['common.threadDisplay.archive']()}
</TooltipContent>
</Tooltip>
{!isFolderBin ? (
@@ -413,7 +412,7 @@ const Thread = memo(
side={index === 0 ? 'bottom' : 'top'}
className="dark:bg-panelDark mb-1 bg-white"
>
{t('common.actions.Bin')}
{m['common.actions.Bin']()}
</TooltipContent>
</Tooltip>
) : null}
@@ -525,7 +524,7 @@ const Thread = memo(
</span>
</TooltipTrigger>
<TooltipContent className="p-1 text-xs">
{t('common.mail.replies', { count: getThreadData.totalReplies })}
{m['common.mail.replies']({ count: getThreadData.totalReplies })}
</TooltipContent>
</Tooltip>
) : null}
@@ -700,14 +699,14 @@ const Draft = memo(({ message }: { message: { id: string } }) => {
</span>
</span>
</div>
{draft.rawMessage?.internalDate && (
<p
className={cn(
'text-muted-foreground text-nowrap text-xs font-normal opacity-70 transition-opacity group-hover:opacity-100 dark:text-[#8C8C8C]',
)}
>
{formatDate(Number(draft.rawMessage?.internalDate))}
</p>
{draft.rawMessage?.internalDate && (
<p
className={cn(
'text-muted-foreground text-nowrap text-xs font-normal opacity-70 transition-opacity group-hover:opacity-100 dark:text-[#8C8C8C]',
)}
>
{formatDate(Number(draft.rawMessage?.internalDate))}
</p>
)}
</div>
<div className="flex justify-between">
@@ -731,7 +730,6 @@ export const MailList = memo(
function MailList() {
const { folder } = useParams<{ folder: string }>();
const { data: settingsData } = useSettings();
const t = useTranslations();
const [, setThreadId] = useQueryState('threadId');
const [, setDraftId] = useQueryState('draftId');
const [category, setCategory] = useQueryState('category');
@@ -965,7 +963,6 @@ export const MailList = memo(
isLoading,
isFetching,
hasNextPage,
t,
],
);
@@ -1049,8 +1046,6 @@ export const MailList = memo(
export const MailLabels = memo(
function MailListLabels({ labels }: { labels: { id: string; name: string }[] }) {
const t = useTranslations();
if (!labels?.length) return null;
const visibleLabels = labels.filter(
@@ -1072,7 +1067,7 @@ export const MailLabels = memo(
</Badge>
</TooltipTrigger>
<TooltipContent className="hidden px-1 py-0 text-xs">
{t('common.notes.title')}
{m['common.notes.title']()}
</TooltipContent>
</Tooltip>
);

View File

@@ -40,6 +40,7 @@ import * as CustomIcons from '@/components/icons/icons';
import { isMac } from '@/lib/hotkeys/use-hotkey-utils';
import { MailList } from '@/components/mail/mail-list';
import { useHotkeysContext } from 'react-hotkeys-hook';
import SelectAllCheckbox from './select-all-checkbox';
import { useNavigate, useParams } from 'react-router';
import { useMail } from '@/components/mail/use-mail';
import { SidebarToggle } from '../ui/sidebar-toggle';
@@ -61,12 +62,11 @@ import { ScrollArea } from '../ui/scroll-area';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { useStats } from '@/hooks/use-stats';
import { useTranslations } from 'use-intl';
import type { IConnection } from '@/types';
import { m } from '@/paraglide/messages';
import { useQueryState } from 'nuqs';
import { useAtom } from 'jotai';
import { toast } from 'sonner';
import SelectAllCheckbox from './select-all-checkbox';
interface ITag {
id: string;
@@ -386,7 +386,6 @@ export function MailLayout() {
const navigate = useNavigate();
const { data: session, isPending } = useSession();
const { data: connections } = useConnections();
const t = useTranslations();
const prevFolderRef = useRef(folder);
const { enableScope, disableScope } = useHotkeysContext();
const { data: activeConnection } = useActiveConnection();
@@ -512,7 +511,7 @@ export function MailLayout() {
</button>
</TooltipTrigger>
<TooltipContent>
{t('common.actions.exitSelectionModeEsc')}
{m['common.actions.exitSelectionModeEsc']()}
</TooltipContent>
</Tooltip>
</div>
@@ -642,7 +641,6 @@ export function MailLayout() {
}
function BulkSelectActions() {
const t = useTranslations();
const [isLoading, setIsLoading] = useState(false);
const [isUnsub, setIsUnsub] = useState(false);
const [mail, setMail] = useMail();
@@ -718,7 +716,7 @@ function BulkSelectActions() {
</div>
</button>
</TooltipTrigger>
<TooltipContent>{t('common.mail.starAll')}</TooltipContent>
<TooltipContent>{m['common.mail.starAll']()}</TooltipContent>
</Tooltip>
<Tooltip>
@@ -735,7 +733,7 @@ function BulkSelectActions() {
</div>
</button>
</TooltipTrigger>
<TooltipContent>{t('common.mail.archive')}</TooltipContent>
<TooltipContent>{m['common.mail.archive']()}</TooltipContent>
</Tooltip>
<Dialog onOpenChange={setIsUnsub} open={isUnsub}>
@@ -763,7 +761,7 @@ function BulkSelectActions() {
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>{t('common.mail.unSubscribeFromAll')}</TooltipContent>
<TooltipContent>{m['common.mail.unSubscribeFromAll']()}</TooltipContent>
</Tooltip>
<DialogContent
@@ -816,14 +814,13 @@ function BulkSelectActions() {
</div>
</button>
</TooltipTrigger>
<TooltipContent>{t('common.mail.moveToBin')}</TooltipContent>
<TooltipContent>{m['common.mail.moveToBin']()}</TooltipContent>
</Tooltip>
</div>
);
}
export const Categories = () => {
const t = useTranslations();
const defaultCategoryIdInner = useDefaultCategoryId();
const categorySettings = useCategorySettings();
const [activeCategory] = useQueryState('category', {
@@ -833,7 +830,13 @@ export const Categories = () => {
const categories = categorySettings.map((cat) => {
const base = {
id: cat.id,
name: t(`common.mailCategories.${cat.id.split(' ').map((w, i) => i === 0 ? w.toLowerCase() : w).join('')}` as any) || cat.name,
name: (() => {
const key = `common.mailCategories.${cat.id
.split(' ')
.map((w, i) => (i === 0 ? w.toLowerCase() : w))
.join('')}` as keyof typeof m;
return m[key] && typeof m[key] === 'function' ? (m[key] as () => string)() : cat.name;
})(),
searchValue: cat.searchValue,
} as const;

View File

@@ -1,16 +0,0 @@
import { useTranslations } from 'use-intl';
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
const NavMain: React.FC = () => {
const t = useTranslations();
const [state, setState] = useState<'collapsed' | 'expanded'>('collapsed');
const handleToggle = () => {
setState((prevState) => (prevState === 'collapsed' ? 'expanded' : 'collapsed'));
};
return <div>{/* Rest of the component code */}</div>;
};
export default NavMain;

View File

@@ -16,7 +16,6 @@ import {
getNoteColorClass,
getNoteColorStyle,
formatRelativeTime,
formatDate,
borderToBackgroundColorClass,
assignOrdersAfterPinnedReorder,
assignOrdersAfterUnpinnedReorder,
@@ -67,7 +66,6 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useState, useRef, useEffect, useMemo } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTranslations, useFormatter } from 'use-intl';
import { useTRPC } from '@/providers/query-provider';
import { Textarea } from '@/components/ui/textarea';
import { useMutation } from '@tanstack/react-query';
@@ -75,6 +73,7 @@ import { useThreadNotes } from '@/hooks/use-notes';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { m } from '@/paraglide/messages';
import { CSS } from '@dnd-kit/utilities';
import type { Note } from '@/types';
import { cn } from '@/lib/utils';
@@ -104,9 +103,6 @@ function SortableNote({
id: note.id,
});
const t = useTranslations();
const format = useFormatter();
const style = {
transform: CSS.Transform.toString(transform),
transition,
@@ -138,7 +134,7 @@ function SortableNote({
<div className="mt-2 flex cursor-default items-center text-xs text-[#8C8C8C]">
<Clock className="mr-1 h-3 w-3" />
<span>{formatRelativeTime(note.createdAt, format)}</span>
<span>{formatRelativeTime(note.createdAt)}</span>
</div>
</div>
@@ -168,14 +164,14 @@ function SortableNote({
className="text-black focus:bg-white focus:text-black dark:text-white/90 dark:focus:bg-[#202020] dark:focus:text-white"
>
<Edit className="mr-2 h-4 w-4" />
<span>{t('common.notes.actions.edit')}</span>
<span>{m['common.notes.actions.edit']()}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onCopy}
className="text-black focus:bg-white focus:text-black dark:text-white/90 dark:focus:bg-[#202020] dark:focus:text-white"
>
<Copy className="mr-2 h-4 w-4" />
<span>{t('common.notes.actions.copy')}</span>
<span>{m['common.notes.actions.copy']()}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onTogglePin}
@@ -184,19 +180,19 @@ function SortableNote({
{note.isPinned ? (
<>
<PinOff className="mr-2 h-4 w-4" />
<span>{t('common.notes.actions.unpin')}</span>
<span>{m['common.notes.actions.unpin']()}</span>
</>
) : (
<>
<Pin className="mr-2 h-4 w-4" />
<span>{t('common.notes.actions.pin')}</span>
<span>{m['common.notes.actions.pin']()}</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-black focus:bg-white focus:text-black dark:text-white/90 dark:focus:bg-[#202020] dark:focus:text-white">
<PaintBucket className="mr-2 h-4 w-4" />
<span>{t('common.notes.actions.changeColor')}</span>
<span>{m['common.notes.actions.changeColor']()}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="dark:bg-panelDark w-48 border-[#E7E7E7] bg-[#FAFAFA] dark:border-[#252525]">
@@ -217,7 +213,7 @@ function SortableNote({
: 'border-border border bg-transparent',
)}
/>
<span>{t(`common.notes.colors.${color.value}` as any)}</span>
<span>{color.label}</span>
</div>
</DropdownMenuRadioItem>
);
@@ -232,7 +228,7 @@ function SortableNote({
className="text-red-600 focus:bg-white focus:text-red-600 dark:text-red-400 dark:focus:bg-[#202020] dark:focus:text-red-400"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t('common.notes.actions.delete')}</span>
<span>{m['common.notes.actions.delete']()}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -259,7 +255,6 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const t = useTranslations();
const trpc = useTRPC();
const { mutateAsync: createNote } = useMutation(trpc.notes.create.mutationOptions());
const { mutateAsync: updateNote } = useMutation(trpc.notes.update.mutationOptions());
@@ -311,9 +306,9 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
};
toast.promise(promise(), {
loading: t('common.actions.loading'),
success: t('common.notes.noteAdded'),
error: t('common.notes.errors.failedToAddNote'),
loading: m['common.actions.loading'](),
success: m['common.notes.noteAdded'](),
error: m['common.notes.errors.failedToAddNote'](),
});
}
};
@@ -348,9 +343,9 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
};
toast.promise(promise(), {
loading: t('common.actions.saving'),
success: t('common.notes.noteUpdated'),
error: t('common.notes.errors.failedToUpdateNote'),
loading: m['common.actions.saving'](),
success: m['common.notes.noteUpdated'](),
error: m['common.notes.errors.failedToUpdateNote'](),
});
}
};
@@ -374,15 +369,15 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
// TODO: Dialog is bugged? needs to be fixed then implement a confirmation dialog
const promise = handleDeleteNote(noteId);
toast.promise(promise, {
loading: t('common.actions.loading'),
success: t('common.notes.noteDeleted'),
error: t('common.notes.errors.failedToDeleteNote'),
loading: m['common.actions.loading'](),
success: m['common.notes.noteDeleted'](),
error: m['common.notes.errors.failedToDeleteNote'](),
});
};
const handleCopyNote = (content: string) => {
navigator.clipboard.writeText(content);
toast.success(t('common.notes.noteCopied'));
toast.success(m['common.notes.noteCopied']());
};
const togglePinNote = async (noteId: string, isPinned: boolean) => {
@@ -392,9 +387,9 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
});
toast.promise(action, {
loading: t('common.actions.loading'),
success: isPinned ? t('common.notes.noteUnpinned') : t('common.notes.notePinned'),
error: t('common.notes.errors.failedToUpdateNote'),
loading: m['common.actions.loading'](),
success: isPinned ? m['common.notes.noteUnpinned']() : m['common.notes.notePinned'](),
error: m['common.notes.errors.failedToUpdateNote'](),
});
await action;
@@ -410,9 +405,9 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
});
toast.promise(action, {
loading: t('common.actions.loading'),
success: t('common.notes.colorChanged'),
error: t('common.notes.errors.failedToUpdateNoteColor'),
loading: m['common.actions.loading'](),
success: m['common.notes.colorChanged'](),
error: m['common.notes.errors.failedToUpdateNoteColor'](),
});
await action;
@@ -449,9 +444,9 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
const action = reorderNotes({ notes: newNotes });
toast.promise(action, {
loading: t('common.actions.loading'),
success: t('common.notes.notesReordered'),
error: t('common.notes.errors.failedToReorderNotes'),
loading: m['common.actions.loading'](),
success: m['common.notes.notesReordered'](),
error: m['common.notes.errors.failedToReorderNotes'](),
});
await action;
@@ -470,9 +465,9 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
const action = reorderNotes({ notes: newNotes });
toast.promise(action, {
loading: t('common.actions.loading'),
success: t('common.notes.notesReordered'),
error: t('common.notes.errors.failedToReorderNotes'),
loading: m['common.actions.loading'](),
success: m['common.notes.notesReordered'](),
error: m['common.notes.errors.failedToReorderNotes'](),
});
await action;
@@ -531,11 +526,11 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
{notes.length}
</span>
)}
<span className="sr-only">{t('common.notes.title')}</span>
<span className="sr-only">{m['common.notes.title']()}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="bg-white dark:bg-[#313131]">
<p>{t('common.notes.noteCount', { count: notes.length })}</p>
<p>{m['common.notes.noteCount']({ count: notes.length })}</p>
</TooltipContent>
</Tooltip>
@@ -547,7 +542,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-[#E7E7E7] p-3 dark:border-[#252525]">
<h3 className="flex items-center text-sm font-medium text-black dark:text-white">
<StickyNote className="mr-2 h-4 w-4" />
{t('common.notes.title')}{' '}
{m['common.notes.title']()}{' '}
{notes.length > 0 && (
<Badge variant="outline" className="ml-2">
{notes.length}
@@ -561,7 +556,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
onClick={() => setIsOpen(false)}
>
<X className="h-4 w-4 fill-[#9A9A9A]" />
<span className="sr-only">{t('common.actions.close')}</span>
<span className="sr-only">{m['common.actions.close']()}</span>
</Button>
</div>
@@ -570,7 +565,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-[#9A9A9A]" />
<Input
placeholder={t('common.notes.search')}
placeholder={m['common.notes.search']()}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border-[#E7E7E7] bg-white pl-8 text-sm text-black placeholder:text-[#797979] focus:outline-none dark:border-[#252525] dark:bg-[#202020] dark:text-white"
@@ -592,10 +587,10 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
<div className="flex flex-col items-center justify-center py-8 text-center">
<StickyNote className="mb-2 h-12 w-12 text-[#8C8C8C] opacity-50" />
<p className="text-sm text-black dark:text-white/90">
{t('common.notes.empty')}
{m['common.notes.empty']()}
</p>
<p className="mb-4 mt-1 max-w-[80%] text-xs text-[#8C8C8C]">
{t('common.notes.emptyDescription')}
{m['common.notes.emptyDescription']()}
</p>
<Button
variant="default"
@@ -604,7 +599,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
onClick={() => setIsAddingNewNote(true)}
>
<PlusCircle className="mr-1 h-4 w-4" />
{t('common.notes.addNote')}
{m['common.notes.addNote']()}
</Button>
</div>
) : (
@@ -613,7 +608,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
<div className="flex flex-col items-center justify-center py-6 text-center">
<AlertCircle className="mb-2 h-10 w-10 text-[#8C8C8C] opacity-50" />
<p className="text-sm text-black dark:text-white/90">
{t('common.notes.noMatchingNotes', { query: searchQuery })}
{m['common.notes.noMatchingNotes']({ query: searchQuery })}
</p>
<Button
variant="outline"
@@ -621,7 +616,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
className="mt-4 border-[#E7E7E7] bg-white text-black dark:border-[#252525] dark:bg-[#313131] dark:text-white/90"
onClick={() => setSearchQuery('')}
>
{t('common.notes.clearSearch')}
{m['common.notes.clearSearch']()}
</Button>
</div>
) : (
@@ -631,7 +626,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
<div className="mb-2 flex items-center">
<Pin className="mr-1 h-3 w-3 text-amber-500" />
<span className="text-muted-foreground text-xs font-medium">
{t('common.notes.pinnedNotes')}
{m['common.notes.pinnedNotes']()}
</span>
</div>
@@ -659,7 +654,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
{sortedPinnedNotes.length > 0 && sortedUnpinnedNotes.length > 0 && (
<div className="mb-2 flex items-center">
<span className="text-muted-foreground text-xs font-medium">
{t('common.notes.otherNotes')}
{m['common.notes.otherNotes']()}
</span>
</div>
)}
@@ -706,13 +701,13 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
onChange={(e) => setNewNoteContent(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, 'add')}
className="min-h-[20px] resize-none border-none bg-transparent text-black focus:outline-none dark:text-white/90"
placeholder={t('common.notes.addYourNote')}
placeholder={m['common.notes.addYourNote']()}
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-y-2 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-xs text-[#8C8C8C]">
{t('common.notes.label')}
{m['common.notes.label']()}
</span>
<div className="flex flex-wrap gap-1.5">
{NOTE_COLORS.map((color) => (
@@ -733,16 +728,14 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
selectedColor === color.value &&
'ring-primary ring-2 ring-offset-1',
)}
aria-label={t(
`common.notes.colors.${color.value}` as any,
)}
aria-label={color.label}
/>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="bg-white dark:bg-[#313131]"
>
{t(`common.notes.colors.${color.value}` as any)}
{color.label}
</TooltipContent>
</Tooltip>
))}
@@ -760,7 +753,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
setNewNoteContent('');
}}
>
{t('common.notes.cancel')}
{m['common.notes.cancel']()}
</Button>
<Button
variant="default"
@@ -768,7 +761,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
onClick={() => void handleAddNote()}
disabled={!newNoteContent.trim()}
>
{t('common.notes.save')}
{m['common.notes.save']()}
</Button>
</div>
</div>
@@ -783,7 +776,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
onClick={() => setIsAddingNewNote(true)}
>
<PlusCircle className="mr-2 h-4 w-4" />
{t('common.notes.addNote')}
{m['common.notes.addNote']()}
</Button>
)}
</>
@@ -808,7 +801,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
<div className="dark:bg-panelDark border-t border-[#E7E7E7] bg-[#FAFAFA] p-3 dark:border-[#252525]">
<div className="space-y-2">
<div className="mb-1 text-xs font-medium text-[#8C8C8C]">
{t('common.notes.editNote')}:
{m['common.notes.editNote']()}:
</div>
<Textarea
ref={textareaRef}
@@ -816,7 +809,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, 'edit')}
className="min-h-[100px] resize-none border-[#E7E7E7] bg-[#FFFFFF] text-sm text-black dark:border-[#252525] dark:bg-[#202020] dark:text-white/90"
placeholder={t('common.notes.addYourNote')}
placeholder={m['common.notes.addYourNote']()}
/>
<div className="flex justify-end gap-2">
@@ -829,10 +822,10 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
setEditContent('');
}}
>
{t('common.notes.cancel')}
{m['common.notes.cancel']()}
</Button>
<Button variant="default" size="xs" onClick={() => void handleEditNote()}>
{t('common.actions.saveChanges')}
{m['common.actions.saveChanges']()}
</Button>
</div>
</div>

View File

@@ -11,7 +11,7 @@ import { useSession } from '@/lib/auth-client';
import { serializeFiles } from '@/lib/schemas';
import { useDraft } from '@/hooks/use-drafts';
import { useEffect, useState } from 'react';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import type { Sender } from '@/types';
import { useQueryState } from 'nuqs';
import posthog from 'posthog-js';
@@ -25,7 +25,7 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) {
const [mode, setMode] = useQueryState('mode');
const { enableScope, disableScope } = useHotkeysContext();
const { data: aliases, isLoading: isLoadingAliases } = useEmailAliases();
const t = useTranslations();
const [draftId, setDraftId] = useQueryState('draftId');
const [threadId] = useQueryState('threadId');
const [, setActiveReplyId] = useQueryState('activeReplyId');
@@ -211,10 +211,10 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) {
// Reset states
setMode(null);
await refetch();
toast.success(t('pages.createEmail.emailSent'));
toast.success(m['pages.createEmail.emailSent']());
} catch (error) {
console.error('Error sending email:', error);
toast.error(t('pages.createEmail.failedToSendEmail'));
toast.error(m['pages.createEmail.failedToSendEmail']());
}
};

View File

@@ -41,7 +41,7 @@ import { useIsMobile } from '@/hooks/use-mobile';
import { Button } from '@/components/ui/button';
import { useStats } from '@/hooks/use-stats';
import ReplyCompose from './reply-composer';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { NotesPanel } from './note-panel';
import { cn, FOLDERS } from '@/lib/utils';
import MailDisplay from './mail-display';
@@ -188,7 +188,7 @@ export function ThreadDisplay() {
return acc;
}, []);
}, [emailData?.messages]);
const t = useTranslations();
const { refetch: refetchStats } = useStats();
const [mode, setMode] = useQueryState('mode');
const [, setBackgroundQueue] = useAtom(backgroundQueueAtom);
@@ -686,7 +686,7 @@ export function ThreadDisplay() {
await toggleImportant({ ids: [id] });
await refetchThread();
if (isImportant) {
toast.success(t('common.mail.markedAsImportant'));
toast.success(m['common.mail.markedAsImportant']());
} else {
toast.error('Failed to mark as important');
}
@@ -818,13 +818,13 @@ export function ThreadDisplay() {
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="bg-white dark:bg-[#313131]">
{t('common.actions.close')}
{m['common.actions.close']()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<ThreadActionButton
icon={X}
label={t('common.actions.close')}
label={m['common.actions.close']()}
onClick={handleClose}
className="hidden md:flex"
/>
@@ -887,7 +887,7 @@ export function ThreadDisplay() {
<Reply className="fill-muted-foreground dark:fill-[#9B9B9B]" />
<div className="flex items-center justify-center gap-2.5 pl-0.5 pr-1">
<div className="justify-start text-sm leading-none text-black dark:text-white">
{t('common.threadDisplay.replyAll')}
{m['common.threadDisplay.replyAll']()}
</div>
</div>
</button>
@@ -911,8 +911,8 @@ export function ThreadDisplay() {
</TooltipTrigger>
<TooltipContent side="bottom" className="bg-white dark:bg-[#313131]">
{isStarred
? t('common.threadDisplay.unstar')
: t('common.threadDisplay.star')}
? m['common.threadDisplay.unstar']()
: m['common.threadDisplay.star']()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -928,7 +928,7 @@ export function ThreadDisplay() {
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="bg-white dark:bg-[#313131]">
{t('common.threadDisplay.archive')}
{m['common.threadDisplay.archive']()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -945,7 +945,7 @@ export function ThreadDisplay() {
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="bg-white dark:bg-[#313131]">
{t('common.mail.moveToBin')}
{m['common.mail.moveToBin']()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -970,7 +970,7 @@ export function ThreadDisplay() {
{isInSpam || isInArchive || isInBin ? (
<DropdownMenuItem onClick={() => moveThreadTo('inbox')}>
<Inbox className="mr-2 h-4 w-4" />
<span>{t('common.mail.moveToInbox')}</span>
<span>{m['common.mail.moveToInbox']()}</span>
</DropdownMenuItem>
) : (
<>
@@ -981,17 +981,17 @@ export function ThreadDisplay() {
}}
>
<Printer className="fill-iconLight dark:fill-iconDark mr-2 h-4 w-4" />
<span>{t('common.threadDisplay.printThread')}</span>
<span>{m['common.threadDisplay.printThread']()}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveThreadTo('spam')}>
<ArchiveX className="fill-iconLight dark:fill-iconDark mr-2" />
<span>{t('common.threadDisplay.moveToSpam')}</span>
<span>{m['common.threadDisplay.moveToSpam']()}</span>
</DropdownMenuItem>
{emailData.latest?.listUnsubscribe ||
emailData.latest?.listUnsubscribePost ? (
<DropdownMenuItem onClick={handleUnsubscribeProcess}>
<Folders className="fill-iconLight dark:fill-iconDark mr-2" />
<span>{t('common.mailDisplay.unsubscribe')}</span>
<span>{m['common.mailDisplay.unsubscribe']()}</span>
</DropdownMenuItem>
) : null}
</>
@@ -999,7 +999,7 @@ export function ThreadDisplay() {
{!isImportant && (
<DropdownMenuItem onClick={handleToggleImportant}>
<Lightning className="fill-iconLight dark:fill-iconDark mr-2" />
{t('common.mail.markAsImportant')}
{m['common.mail.markAsImportant']()}
</DropdownMenuItem>
)}
</DropdownMenuContent>

View File

@@ -5,11 +5,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useEffect, useState } from 'react';
import { type MessageKey } from '@/config/navigation';
import { Laptop, Moon, Sun } from 'lucide-react';
import { useTranslations } from 'use-intl';
import { useEffect, useState } from 'react';
import { m } from '@/paraglide/messages';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
interface ModeToggleProps {
@@ -19,8 +17,6 @@ interface ModeToggleProps {
export function ModeToggle({ className }: ModeToggleProps) {
const [mounted, setMounted] = useState(false);
const t = useTranslations();
// Fixes SSR hydration
useEffect(() => {
setMounted(true);
@@ -60,7 +56,7 @@ export function ModeToggle({ className }: ModeToggleProps) {
{theme === 'dark' && <Moon className="h-4 w-4" />}
{theme === 'light' && <Sun className="h-4 w-4" />}
{theme === 'system' && <Laptop className="h-4 w-4" />}
{t(`common.themes.${theme}` as MessageKey)}
{m[`common.themes.${theme as 'dark' | 'light' | 'system'}`]()}
</div>
</SelectValue>
</SelectTrigger>
@@ -68,19 +64,19 @@ export function ModeToggle({ className }: ModeToggleProps) {
<SelectItem value="dark">
<div className="flex items-center gap-2">
<Moon className="h-4 w-4" />
{t('common.themes.dark')}
{m['common.themes.dark']()}
</div>
</SelectItem>
<SelectItem value="system">
<div className="flex items-center gap-2">
<Laptop className="h-4 w-4" />
{t('common.themes.system')}
{m['common.themes.system']()}
</div>
</SelectItem>
<SelectItem value="light">
<div className="flex items-center gap-2">
<Sun className="h-4 w-4" />
{t('common.themes.light')}
{m['common.themes.light']()}
</div>
</SelectItem>
</SelectContent>

View File

@@ -41,8 +41,8 @@ import { Button } from '@/components/ui/button';
import { useAIFullScreen } from './ai-sidebar';
import { useStats } from '@/hooks/use-stats';
import { useLocation } from 'react-router';
import { useTranslations } from 'use-intl';
import { useForm } from 'react-hook-form';
import { m } from '@/paraglide/messages';
import { FOLDERS } from '@/lib/utils';
import { NavUser } from './nav-user';
import { NavMain } from './nav-main';
@@ -177,7 +177,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
function ComposeButton() {
const { state } = useSidebar();
const isMobile = useIsMobile();
const t = useTranslations();
const [dialogOpen, setDialogOpen] = useQueryState('isComposeOpen');
const [, setDraftId] = useQueryState('draftId');
@@ -206,7 +205,7 @@ function ComposeButton() {
<div className="flex items-center justify-center gap-2.5 pl-0.5 pr-1">
<PencilCompose className="fill-iconLight dark:fill-iconDark" />
<div className="justify-start text-sm leading-none">
{t('common.commandPalette.commands.newEmail')}
{m['common.commandPalette.commands.newEmail']()}
</div>
</div>
)}

View File

@@ -1,7 +1,6 @@
import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from './sidebar';
import { Collapsible, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useActiveConnection, useConnections } from '@/hooks/use-connections';
import { type MessageKey, type NavItem } from '@/config/navigation';
import { LabelDialog } from '@/components/labels/label-dialog';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate } from 'react-router';
@@ -9,7 +8,9 @@ import Intercom, { show } from '@intercom/messenger-js-sdk';
import { MessageSquare, OldPhone } from '../icons/icons';
import { useSidebar } from '../context/sidebar-context';
import { useTRPC } from '@/providers/query-provider';
import { type NavItem } from '@/config/navigation';
import type { Label as LabelType } from '@/types';
import { m } from '../../paraglide/messages.js';
import { Button } from '@/components/ui/button';
import { useLabels } from '@/hooks/use-labels';
import { Badge } from '@/components/ui/badge';
@@ -17,7 +18,6 @@ import { useStats } from '@/hooks/use-stats';
import SidebarLabels from './sidebar-labels';
import { useCallback, useRef } from 'react';
import { BASE_URL } from '@/lib/constants';
import { useTranslations } from 'use-intl';
import { useQueryState } from 'nuqs';
import { Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -171,7 +171,6 @@ export function NavMain({ items }: NavMainProps) {
},
[pathname, searchParams],
);
const t = useTranslations();
const onSubmit = async (data: LabelType) => {
toast.promise(createLabel(data), {
@@ -188,7 +187,7 @@ export function NavMain({ items }: NavMainProps) {
<>
<SidebarMenuButton
onClick={() => show()}
tooltip={state === 'collapsed' ? t('help' as MessageKey) : undefined}
tooltip={state === 'collapsed' ? m['common.commandPalette.groups.help']() : undefined}
className="hover:bg-subtleWhite flex cursor-pointer items-center dark:hover:bg-[#202020]"
>
<OldPhone className="relative mr-2.5 h-2 w-2 fill-[#8F8F8F]" />
@@ -201,7 +200,7 @@ export function NavMain({ items }: NavMainProps) {
url={'https://feedback.0.email'}
icon={MessageSquare}
target={'_blank'}
title={'navigation.sidebar.feedback'}
title={m['navigation.sidebar.feedback']()}
/>
</>
) : null}
@@ -274,19 +273,17 @@ export function NavMain({ items }: NavMainProps) {
function NavItem(item: NavItemProps & { href: string }) {
const iconRef = useRef<IconRefType>(null);
const { data: stats } = useStats();
const t = useTranslations();
const { state, setOpenMobile } = useSidebar();
if (item.disabled) {
return (
<SidebarMenuButton
tooltip={state === 'collapsed' ? t(item.title as MessageKey) : undefined}
tooltip={state === 'collapsed' ? item.title : undefined}
className="flex cursor-not-allowed items-center opacity-50"
>
{item.icon && <item.icon ref={iconRef} className="relative mr-2.5 h-3 w-3.5" />}
<p className="relative bottom-[1px] mt-0.5 truncate text-[13px]">
{t(item.title as MessageKey)}
</p>
<p className="relative bottom-[1px] mt-0.5 truncate text-[13px]">{item.title}</p>
</SidebarMenuButton>
);
}
@@ -303,7 +300,7 @@ function NavItem(item: NavItemProps & { href: string }) {
<CollapsibleTrigger asChild>
<SidebarMenuButton
asChild
tooltip={state === 'collapsed' ? t(item.title as MessageKey) : undefined}
tooltip={state === 'collapsed' ? item.title : undefined}
className={cn(
'hover:bg-subtleWhite flex items-center dark:hover:bg-[#202020]',
item.isActive && 'bg-subtleWhite text-accent-foreground dark:bg-[#202020]',
@@ -313,7 +310,7 @@ function NavItem(item: NavItemProps & { href: string }) {
<Link target={item.target} to={item.href}>
{item.icon && <item.icon ref={iconRef} className="mr-2 shrink-0" />}
<p className="relative bottom-[1px] mt-0.5 min-w-0 flex-1 truncate text-[13px]">
{t(item.title as MessageKey)}
{item.title}
</p>
{stats &&
item.id?.toLowerCase() !== 'sent' &&

View File

@@ -36,7 +36,7 @@ import { useThreads } from '@/hooks/use-threads';
import { useBilling } from '@/hooks/use-billing';
import { SunIcon } from '../icons/animated/sun';
import { clear as idbClear } from 'idb-keyval';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useTheme } from 'next-themes';
import { useQueryState } from 'nuqs';
import { Button } from './button';
@@ -48,7 +48,6 @@ export function NavUser() {
const { data, refetch: refetchConnections } = useConnections();
const [isRendered, setIsRendered] = useState(false);
const { theme, setTheme } = useTheme();
const t = useTranslations();
const { state } = useSidebar();
const trpc = useTRPC();
const [, setThreadId] = useQueryState('threadId');
@@ -90,7 +89,7 @@ export function NavUser() {
if (connectionId === activeConnection?.id) return;
try {
setLoading(true, t('common.navUser.switchingAccounts'));
setLoading(true, m['common.navUser.switchingAccounts']());
setThreadId(null);
@@ -139,7 +138,7 @@ export function NavUser() {
]);
} catch (error) {
console.error('Error switching accounts:', error);
toast.error(t('common.navUser.failedToSwitchAccount'));
toast.error(m['common.navUser.failedToSwitchAccount']());
await refetchActiveConnection();
} finally {
@@ -247,7 +246,7 @@ export function NavUser() {
<div className="space-y-1">
<>
<p className="text-muted-foreground px-2 py-1 text-[11px] font-medium">
{t('common.navUser.accounts')}
{m['common.navUser.accounts']()}
</p>
{data?.connections
@@ -296,14 +295,14 @@ export function NavUser() {
) : (
<SunIcon className="size-4 opacity-60" />
)}
<p className="text-[13px] opacity-60">{t('common.navUser.appTheme')}</p>
<p className="text-[13px] opacity-60">{m['common.navUser.appTheme']()}</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={getSettingsHref()} className="cursor-pointer">
<div className="flex items-center gap-2">
<Settings size={16} className="opacity-60" />
<p className="text-[13px] opacity-60">{t('common.actions.settings')}</p>
<p className="text-[13px] opacity-60">{m['common.actions.settings']()}</p>
</div>
</a>
</DropdownMenuItem>
@@ -312,7 +311,7 @@ export function NavUser() {
<div className="flex items-center gap-2">
<HelpCircle size={16} className="opacity-60" />
<p className="text-[13px] opacity-60">
{t('common.navUser.customerSupport')}
{m['common.navUser.customerSupport']()}
</p>
</div>
</a>
@@ -320,7 +319,7 @@ export function NavUser() {
<DropdownMenuItem className="cursor-pointer" onClick={handleLogout}>
<div className="flex items-center gap-2">
<LogOut size={16} className="opacity-60" />
<p className="text-[13px] opacity-60">{t('common.actions.logout')}</p>
<p className="text-[13px] opacity-60">{m['common.actions.logout']()}</p>
</div>
</DropdownMenuItem>
</>
@@ -522,7 +521,7 @@ export function NavUser() {
) : (
<SunIcon className="size-4 opacity-60" />
)}
<p className="text-[13px] opacity-60">{t('common.navUser.appTheme')}</p>
<p className="text-[13px] opacity-60">{m['common.navUser.appTheme']()}</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem>
@@ -530,7 +529,7 @@ export function NavUser() {
<div className="flex items-center gap-2">
<HelpCircle size={16} className="opacity-60" />
<p className="text-[13px] opacity-60">
{t('common.navUser.customerSupport')}
{m['common.navUser.customerSupport']()}
</p>
</div>
</a>
@@ -538,7 +537,7 @@ export function NavUser() {
<DropdownMenuItem className="cursor-pointer" onClick={handleLogout}>
<div className="flex items-center gap-2">
<LogOut size={16} className="opacity-60" />
<p className="text-[13px] opacity-60">{t('common.actions.logout')}</p>
<p className="text-[13px] opacity-60">{m['common.actions.logout']()}</p>
</div>
</DropdownMenuItem>
</div>
@@ -573,12 +572,12 @@ export function NavUser() {
</div>
)}
</div>
{state !== 'collapsed' && (
<div className="flex items-center justify-between gap-2 mt-2">
<div className="mt-2 flex items-center justify-between gap-2">
<div className="mt-[2px] flex flex-col items-start gap-1 space-y-1">
<div className="flex items-center gap-1 text-[13px] leading-none text-black dark:text-white">
<p className={cn('truncate text-[13px] max-w-[14.5ch]')}>
<p className={cn('max-w-[14.5ch] truncate text-[13px]')}>
{activeAccount?.name || session.user.name || 'User'}
</p>
{isPro ? (
@@ -589,14 +588,14 @@ export function NavUser() {
{activeAccount?.email || session.user.email}
</div>
{!isPro && (
<button
onClick={() => setPricingDialog('true')}
className="flex h-5 items-center gap-1 rounded-full border px-1 pr-1.5 hover:bg-transparent"
>
<BadgeCheck className="h-4 w-4 text-white dark:text-[#141414]" fill="#1D9BF0" />
<span className="text-muted-foreground text-[10px] uppercase">Get verified</span>
</button>
)}
<button
onClick={() => setPricingDialog('true')}
className="flex h-5 items-center gap-1 rounded-full border px-1 pr-1.5 hover:bg-transparent"
>
<BadgeCheck className="h-4 w-4 text-white dark:text-[#141414]" fill="#1D9BF0" />
<span className="text-muted-foreground text-[10px] uppercase">Get verified</span>
</button>
)}
</div>
<div className="ml-2">{/* Gauge component removed */}</div>

View File

@@ -18,15 +18,12 @@ import {
Plane2,
LockIcon,
} from '@/components/icons/icons';
import type { NestedKeyOf, MessageKeys } from 'use-intl';
import type { IntlMessages } from '@/i18n/config';
import { MessageSquareIcon } from 'lucide-react';
export type MessageKey = MessageKeys<IntlMessages, NestedKeyOf<IntlMessages>>;
import { m } from '@/paraglide/messages';
export interface NavItem {
id?: string;
title: MessageKey | (string & {});
title: string;
url: string;
icon: React.ComponentType<any>;
badge?: number;
@@ -57,21 +54,21 @@ export const navigationConfig: Record<string, NavConfig> = {
items: [
{
id: 'inbox',
title: 'navigation.sidebar.inbox',
title: m['navigation.sidebar.inbox'](),
url: '/mail/inbox',
icon: Inbox,
shortcut: 'g + i',
},
{
id: 'drafts',
title: 'navigation.sidebar.drafts',
title: m['navigation.sidebar.drafts'](),
url: '/mail/draft',
icon: Folder,
shortcut: 'g + d',
},
{
id: 'sent',
title: 'navigation.sidebar.sent',
title: m['navigation.sidebar.sent'](),
url: '/mail/sent',
icon: Plane2,
shortcut: 'g + t',
@@ -83,20 +80,20 @@ export const navigationConfig: Record<string, NavConfig> = {
items: [
{
id: 'archive',
title: 'navigation.sidebar.archive',
title: m['navigation.sidebar.archive'](),
url: '/mail/archive',
icon: Archive,
shortcut: 'g + a',
},
{
id: 'spam',
title: 'navigation.sidebar.spam',
title: m['navigation.sidebar.spam'](),
url: '/mail/spam',
icon: ExclamationCircle,
},
{
id: 'trash',
title: 'navigation.sidebar.bin',
title: m['navigation.sidebar.bin'](),
url: '/mail/bin',
icon: Bin,
},
@@ -140,51 +137,51 @@ export const navigationConfig: Record<string, NavConfig> = {
title: 'Settings',
items: [
{
title: 'common.actions.back',
title: m['common.actions.back'](),
url: '/mail',
icon: ArrowLeft,
isBackButton: true,
},
{
title: 'navigation.settings.general',
title: m['navigation.settings.general'](),
url: '/settings/general',
icon: SettingsGear,
shortcut: 'g + s',
},
{
title: 'navigation.settings.connections',
title: m['navigation.settings.connections'](),
url: '/settings/connections',
icon: Users,
},
{
title: 'navigation.settings.privacy',
title: m['navigation.settings.privacy'](),
url: '/settings/privacy',
icon: LockIcon,
},
{
title: 'navigation.settings.appearance',
title: m['navigation.settings.appearance'](),
url: '/settings/appearance',
icon: Stars,
},
{
title: 'navigation.settings.labels',
title: m['navigation.settings.labels'](),
url: '/settings/labels',
icon: Sheet,
},
{
title: 'navigation.settings.categories',
title: m['navigation.settings.categories'](),
url: '/settings/categories',
icon: Tabs,
},
{
title: 'navigation.settings.signatures',
title: m['navigation.settings.signatures'](),
url: '/settings/signatures',
icon: MessageSquareIcon,
disabled: true,
},
{
title: 'navigation.settings.shortcuts',
title: m['navigation.settings.shortcuts'](),
url: '/settings/shortcuts',
icon: Tabs,
shortcut: '?',
@@ -207,7 +204,7 @@ export const navigationConfig: Record<string, NavConfig> = {
// icon: BellIcon,
// },
{
title: 'navigation.settings.deleteAccount',
title: m['navigation.settings.deleteAccount'](),
url: '/settings/danger-zone',
icon: Danger,
},
@@ -226,7 +223,7 @@ export const bottomNavItems = [
items: [
{
id: 'settings',
title: 'navigation.sidebar.settings',
title: m['navigation.sidebar.settings'](),
url: '/settings/general',
icon: SettingsGear,
isSettingsButton: true,

View File

@@ -4,7 +4,7 @@ import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { useThreads } from '@/hooks/use-threads';
import { useStats } from '@/hooks/use-stats';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -13,7 +13,6 @@ const useDelete = () => {
const [mail, setMail] = useMail();
const [{ refetch: refetchThreads }] = useThreads();
const { refetch: refetchStats } = useStats();
const t = useTranslations();
const { addToQueue, deleteFromQueue } = useBackgroundQueue();
const trpc = useTRPC();
const { mutateAsync: deleteThread } = useMutation(trpc.mail.delete.mutationOptions());
@@ -27,12 +26,12 @@ const useDelete = () => {
id,
}),
{
loading: t('common.actions.deletingMail'),
success: t('common.actions.deletedMail'),
loading: m['common.actions.deletingMail'](),
success: m['common.actions.deletedMail'](),
error: (error) => {
console.error(`Error deleting ${type}:`, error);
return t('common.actions.failedToDeleteMail');
return m['common.actions.failedToDeleteMail']();
},
finally: async () => {
setMail({

View File

@@ -2,11 +2,10 @@ import { useActiveConnection } from './use-connections';
import { useTRPC } from '@/providers/query-provider';
import { useQuery } from '@tanstack/react-query';
import { useSession } from '@/lib/auth-client';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import type { Note } from '@/types';
export const useThreadNotes = (threadId: string) => {
const t = useTranslations();
const { data: session } = useSession();
const trpc = useTRPC();
const { data: activeConnection } = useActiveConnection();
@@ -19,7 +18,7 @@ export const useThreadNotes = (threadId: string) => {
staleTime: 1000 * 60 * 5, // 5 minutes
initialData: { notes: [] as Note[] },
meta: {
customError: t('common.notes.errors.failedToLoadNotes'),
customError: m['common.notes.errors.failedToLoadNotes'](),
},
},
),

View File

@@ -8,13 +8,12 @@ import { useTRPC } from '@/providers/query-provider';
import { useMail } from '@/components/mail/use-mail';
import { moveThreadsTo } from '@/lib/thread-actions';
import { useCallback, useRef } from 'react';
import { useTranslations } from 'use-intl';
import { m } from '@/paraglide/messages';
import { useQueryState } from 'nuqs';
import { useAtom } from 'jotai';
import { toast } from 'sonner';
export function useOptimisticActions() {
const t = useTranslations();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [, setBackgroundQueue] = useAtom(backgroundQueueAtom);
@@ -236,8 +235,8 @@ export function useOptimisticActions() {
removeOptimisticAction(optimisticId);
},
toastMessage: starred
? t('common.actions.addedToFavorites')
: t('common.actions.removedFromFavorites'),
? m['common.actions.addedToFavorites']()
: m['common.actions.removedFromFavorites'](),
});
}
@@ -266,12 +265,12 @@ export function useOptimisticActions() {
}
const successMessage =
destination === 'inbox'
? t('common.actions.movedToInbox')
? m['common.actions.movedToInbox']()
: destination === 'spam'
? t('common.actions.movedToSpam')
? m['common.actions.movedToSpam']()
: destination === 'bin'
? t('common.actions.movedToBin')
: t('common.actions.archived');
? m['common.actions.movedToBin']()
: m['common.actions.archived']();
createPendingAction({
type: 'MOVE',
@@ -346,7 +345,7 @@ export function useOptimisticActions() {
setBackgroundQueue({ type: 'delete', threadId: `thread:${id}` });
});
},
toastMessage: t('common.actions.movedToBin'),
toastMessage: m['common.actions.movedToBin'](),
});
}

View File

@@ -1,45 +0,0 @@
import type enLocale from '../locales/en.json';
const LANGUAGES = {
en: 'English',
ar: 'Arabic',
zh_TW: 'Chinese (Traditional)',
zh_CN: 'Chinese (Simplified)',
ca: 'Catalan',
de: 'German',
es: 'Spanish',
fr: 'French',
hi: 'Hindi',
ja: 'Japanese',
ko: 'Korean',
pl: 'Polish',
pt: 'Portuguese',
ru: 'Russian',
tr: 'Turkish',
lv: 'Latvian',
hu: 'Hungarian',
fa: 'Farsi',
vi: 'Vietnamese',
} as const;
export type Locale = keyof typeof LANGUAGES;
export type IntlMessages = typeof enLocale;
export const languageConfig = LANGUAGES;
export const defaultLocale = 'en';
export const locales: Locale[] = Object.keys(LANGUAGES) as Locale[];
export const availableLocales = locales.map((code) => ({
code,
name: LANGUAGES[code],
}));
declare module 'use-intl' {
interface AppConfig {
Locale: Locale;
Messages: IntlMessages;
}
}

View File

@@ -1,41 +0,0 @@
import { locales, defaultLocale, type Locale, type IntlMessages } from './config';
import acceptLanguageParser from 'accept-language-parser';
import { I18N_LOCALE_COOKIE_NAME } from '@/lib/constants';
import deepmerge from 'deepmerge';
export const resolveLocale = (request: Request) => {
const intlCookie = request.headers
.get('cookie')
?.split(';')
.find((c) => c.trim().startsWith(`${I18N_LOCALE_COOKIE_NAME}=`))
?.split('=')[1]
?.trim();
const locale =
intlCookie && locales.includes(intlCookie as Locale)
? intlCookie
: acceptLanguageParser.pick(
locales,
request.headers.get('accept-language') || defaultLocale,
) || defaultLocale;
return locale as Locale;
};
const allLocales = import.meta.glob('../locales/*.json');
const getDefaultMessages = async () => {
const defaultMessages = (await allLocales['../locales/en.json']()) as IntlMessages;
return defaultMessages;
};
export const getMessages: (locale: string) => Promise<IntlMessages> = async (locale: string) => {
if (locale !== 'en') {
const messages = (await allLocales[`../locales/${locale}.json`]?.()) ?? null;
if (!messages) {
const defaultMessages = await getDefaultMessages();
return defaultMessages;
}
return messages as IntlMessages;
}
return await getDefaultMessages();
};

View File

@@ -8,15 +8,14 @@ import { Categories } from '@/components/mail/mail';
import { useShortcuts } from './use-hotkey-utils';
import { useThreads } from '@/hooks/use-threads';
import { cleanSearchValue } from '@/lib/utils';
import { useTranslations } from 'use-intl';
import { useQueryState } from 'nuqs';
import { toast } from 'sonner';
import { m } from '@/paraglide/messages';
export function MailListHotkeys() {
const scope = 'mail-list';
const [mail, setMail] = useMail();
const [{}, items] = useThreads();
const t = useTranslations();
const hoveredEmailId = useRef<string | null>(null);
const categories = Categories();
const [, setCategory] = useQueryState('category');
@@ -59,7 +58,7 @@ export function MailListHotkeys() {
bulkSelected: allIds,
}));
} else {
toast.info(t('common.mail.noEmailsToSelect'));
toast.info(m['common.mail.noEmailsToSelect']());
}
}, [items, mail]);
@@ -71,12 +70,12 @@ export function MailListHotkeys() {
const idsToMark = mail.bulkSelected;
if (idsToMark.length === 0) {
toast.info(t('common.mail.noEmailsToSelect'));
toast.info(m['common.mail.noEmailsToSelect']());
return;
}
optimisticMarkAsRead(idsToMark);
}, [mail.bulkSelected, optimisticMarkAsRead, t, shouldUseHover]);
}, [mail.bulkSelected, optimisticMarkAsRead, shouldUseHover]);
const markAsUnread = useCallback(() => {
if (shouldUseHover && hoveredEmailId.current) {
@@ -86,12 +85,12 @@ export function MailListHotkeys() {
const idsToMark = mail.bulkSelected;
if (idsToMark.length === 0) {
toast.info(t('common.mail.noEmailsToSelect'));
toast.info(m['common.mail.noEmailsToSelect']());
return;
}
optimisticMarkAsUnread(idsToMark);
}, [mail.bulkSelected, optimisticMarkAsUnread, t, shouldUseHover]);
}, [mail.bulkSelected, optimisticMarkAsUnread, shouldUseHover]);
const markAsImportant = useCallback(() => {
if (shouldUseHover && hoveredEmailId.current) {
@@ -101,12 +100,12 @@ export function MailListHotkeys() {
const idsToMark = mail.bulkSelected;
if (idsToMark.length === 0) {
toast.info(t('common.mail.noEmailsToSelect'));
toast.info(m['common.mail.noEmailsToSelect']());
return;
}
optimisticToggleImportant(idsToMark, true);
}, [mail.bulkSelected, optimisticToggleImportant, t, shouldUseHover]);
}, [mail.bulkSelected, optimisticToggleImportant, shouldUseHover]);
const archiveEmail = useCallback(async () => {
if (shouldUseHover && hoveredEmailId.current) {
@@ -116,12 +115,12 @@ export function MailListHotkeys() {
const idsToArchive = mail.bulkSelected;
if (idsToArchive.length === 0) {
toast.info(t('common.mail.noEmailsToSelect'));
toast.info(m['common.mail.noEmailsToSelect']());
return;
}
optimisticMoveThreadsTo(idsToArchive, folder, 'archive');
}, [mail.bulkSelected, folder, optimisticMoveThreadsTo, t, shouldUseHover]);
}, [mail.bulkSelected, folder, optimisticMoveThreadsTo, shouldUseHover]);
const bulkArchive = useCallback(() => {
if (shouldUseHover && hoveredEmailId.current) {
@@ -131,12 +130,12 @@ export function MailListHotkeys() {
const idsToArchive = mail.bulkSelected;
if (idsToArchive.length === 0) {
toast.info(t('common.mail.noEmailsToSelect'));
toast.info(m['common.mail.noEmailsToSelect']());
return;
}
optimisticMoveThreadsTo(idsToArchive, folder, 'archive');
}, [mail.bulkSelected, folder, optimisticMoveThreadsTo, t, shouldUseHover]);
}, [mail.bulkSelected, folder, optimisticMoveThreadsTo, shouldUseHover]);
const bulkDelete = useCallback(() => {
if (shouldUseHover && hoveredEmailId.current) {
@@ -146,12 +145,12 @@ export function MailListHotkeys() {
const idsToDelete = mail.bulkSelected;
if (idsToDelete.length === 0) {
toast.info(t('common.mail.noEmailsToSelect'));
toast.info(m['common.mail.noEmailsToSelect']());
return;
}
optimisticDeleteThreads(idsToDelete, folder);
}, [mail.bulkSelected, folder, optimisticDeleteThreads, t, shouldUseHover]);
}, [mail.bulkSelected, folder, optimisticDeleteThreads, shouldUseHover]);
const bulkStar = useCallback(() => {
if (shouldUseHover && hoveredEmailId.current) {
@@ -161,12 +160,12 @@ export function MailListHotkeys() {
const idsToStar = mail.bulkSelected;
if (idsToStar.length === 0) {
toast.info(t('common.mail.noEmailsToSelect'));
toast.info(m['common.mail.noEmailsToSelect']());
return;
}
optimisticToggleStar(idsToStar, true);
}, [mail.bulkSelected, optimisticToggleStar, t, shouldUseHover]);
}, [mail.bulkSelected, optimisticToggleStar, shouldUseHover]);
const exitSelectionMode = useCallback(() => {
setMail((prev) => ({

View File

@@ -1,19 +1,26 @@
import { m } from '@/paraglide/messages';
import { formatDate } from './utils';
import type { Note } from '@/types';
import React from 'react';
import { formatDate } from './utils';
export const NOTE_COLORS = [
{ value: 'default', label: 'Default', class: 'border-transparent', bgClass: '', style: {} },
{
value: 'default',
label: m['common.notes.colors.default'](),
class: 'border-transparent',
bgClass: '',
style: {},
},
{
value: 'red',
label: 'Red',
label: m['common.notes.colors.red'](),
class: 'border-l-red-500',
bgClass: 'hover:bg-red-50 dark:hover:bg-red-950/20',
style: { borderLeftColor: 'rgb(239, 68, 68)' },
},
{
value: 'orange',
label: 'Orange',
label: m['common.notes.colors.orange'](),
class: 'border-l-orange-500',
bgClass: 'hover:bg-orange-50 dark:hover:bg-orange-950/20',
style: { borderLeftColor: 'rgb(249, 115, 22)' },
@@ -27,28 +34,28 @@ export const NOTE_COLORS = [
},
{
value: 'green',
label: 'Green',
label: m['common.notes.colors.green'](),
class: 'border-l-green-500',
bgClass: 'hover:bg-green-50 dark:hover:bg-green-950/20',
style: { borderLeftColor: 'rgb(34, 197, 94)' },
},
{
value: 'blue',
label: 'Blue',
label: m['common.notes.colors.blue'](),
class: 'border-l-blue-500',
bgClass: 'hover:bg-blue-50 dark:hover:bg-blue-950/20',
style: { borderLeftColor: 'rgb(59, 130, 246)' },
},
{
value: 'purple',
label: 'Purple',
label: m['common.notes.colors.purple'](),
class: 'border-l-purple-500',
bgClass: 'hover:bg-purple-50 dark:hover:bg-purple-950/20',
style: { borderLeftColor: 'rgb(168, 85, 247)' },
},
{
value: 'pink',
label: 'Pink',
label: m['common.notes.colors.pink'](),
class: 'border-l-pink-500',
bgClass: 'hover:bg-pink-50 dark:hover:bg-pink-950/20',
style: { borderLeftColor: 'rgb(236, 72, 153)' },
@@ -66,10 +73,6 @@ export const NOTE_COLOR_TRANSLATION_KEYS: Record<string, string> = {
pink: 'common.notes.colors.pink',
};
export function getNoteColorTranslationKey(colorValue: string): string {
return NOTE_COLOR_TRANSLATION_KEYS[colorValue] || colorValue;
}
export function getNoteColorClass(color: string): string {
const colorInfo = NOTE_COLORS.find((c) => c.value === color);
return colorInfo?.class || 'border-transparent';
@@ -84,18 +87,11 @@ export function borderToBackgroundColorClass(borderClass: string): string {
return borderClass.replace('border-l-', 'bg-');
}
export function formatRelativeTime(dateInput: string | Date, formatter?: any): string {
export function formatRelativeTime(dateInput: string | Date): string {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (formatter) {
return formatter.relativeTime(date, {
now,
style: 'long',
});
}
if (diffInSeconds < 60) {
return 'just now';
} else if (diffInSeconds < 3600) {
@@ -108,11 +104,10 @@ export function formatRelativeTime(dateInput: string | Date, formatter?: any): s
const days = Math.floor(diffInSeconds / 86400);
return `${days} ${days === 1 ? 'day' : 'days'} ago`;
} else {
return formatDate(date, formatter);
return formatDate(date);
}
}
export function sortNotes(notes: Note[]): Note[] {
const pinnedNotes = notes.filter((note) => note.isPinned);
const unpinnedNotes = notes.filter((note) => !note.isPinned);

View File

@@ -95,19 +95,19 @@ export const parseAndValidateDate = (dateString: string): Date | null => {
*/
export const shouldShowSeparateTime = (dateString: string | undefined): boolean => {
if (!dateString) return false;
const dateObj = parseAndValidateDate(dateString);
if (!dateObj) return false;
const now = new Date();
// Don't show separate time if email is from today
if (isToday(dateObj)) return false;
// Don't show separate time if email is within the last 12 hours
const hoursDifference = (now.getTime() - dateObj.getTime()) / (1000 * 60 * 60);
if (hoursDifference <= 12) return false;
// Show separate time for older emails
return true;
};
@@ -116,24 +116,14 @@ export const shouldShowSeparateTime = (dateString: string | undefined): boolean
* Formats a date with different formatting logic based on parameters
* Overloaded to handle both mail date formatting and notes date formatting
*/
export function formatDate(date: string): string;
export function formatDate(timestamp: number): string;
export function formatDate(dateInput: string | Date | number, formatter?: any): string {
export function formatDate(dateInput: string | Date | number): string {
if (typeof dateInput === 'number') {
dateInput = new Date(dateInput).toISOString();
}
// Notes formatting logic (when formatter is provided or date is a Date object)
if (formatter || dateInput instanceof Date) {
// Notes formatting logic (when date is a Date object)
if (dateInput instanceof Date) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : (dateInput as Date);
if (formatter) {
return formatter.dateTime(date, {
dateStyle: 'medium',
timeStyle: 'short',
});
}
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
@@ -187,7 +177,7 @@ export const formatTime = (date: string) => {
try {
const timezone = getBrowserTimezone();
// Always return the time in h:mm a format
return formatInTimeZone(dateObj, timezone, 'h:mm a');
} catch (error) {

View File

@@ -162,8 +162,34 @@
"thisEmail": "هذا البريد الإلكتروني",
"dropFiles": "أفلت الملفات للإرفاق",
"attachments": "المرفقات",
"attachmentCount": "{count, plural, =0 {مرفقات} one {مرفق} two {مرفقان} few {مرفقات} many {مرفقات} other {مرفقات}}",
"fileCount": "{count, plural, =0 {ملفات} one {ملف} two {ملفان} few {ملفات} many {ملفات} other {ملفات}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "مرفقات",
"countPlural=one": "مرفق",
"countPlural=two": "مرفقان",
"countPlural=few": "مرفقات",
"countPlural=many": "مرفقات",
"countPlural=other": "مرفقات"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "ملفات",
"countPlural=one": "ملف",
"countPlural=two": "ملفان",
"countPlural=few": "ملفات",
"countPlural=many": "ملفات",
"countPlural=other": "ملفات"
}
}
],
"saveDraft": "حفظ المسودة",
"send": "إرسال",
"forward": "إعادة توجيه"
@@ -226,7 +252,20 @@
"toSave": "للحفظ",
"label": "التصنيف:",
"search": "البحث في الملاحظات...",
"noteCount": "{count, plural, =0 {إضافة ملاحظات} one {ملاحظة واحدة} two {ملاحظتان} few {# ملاحظات} many {# ملاحظة} other {# ملاحظة}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "إضافة ملاحظات",
"countPlural=one": "ملاحظة واحدة",
"countPlural=two": "ملاحظتان",
"countPlural=few": "# ملاحظات",
"countPlural=many": "# ملاحظة",
"countPlural=other": "# ملاحظة"
}
}
],
"notePinned": "تم تثبيت الملاحظة",
"noteUnpinned": "تم إلغاء تثبيت الملاحظة",
"colorChanged": "تم تحديث لون الملاحظة",
@@ -279,7 +318,20 @@
"failedToUpdateGmailAlias": "فشل تحديث الاسم المستعار الافتراضي في إعدادات جيميل"
},
"mail": {
"replies": "{count, plural, =0 {ردود} one {رد واحد} two {ردان} few {# ردود} many {# رداً} other {# رد}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "ردود",
"countPlural=one": "رد واحد",
"countPlural=two": "ردان",
"countPlural=few": "# ردود",
"countPlural=many": "# رداً",
"countPlural=other": "# رد"
}
}
],
"deselectAll": "تم إلغاء تحديد جميع الرسائل",
"selectedEmails": "تم تحديد {count} من الرسائل",
"noEmailsToSelect": "لا توجد رسائل للتحديد",
@@ -602,4 +654,4 @@
"early_access_required": "الوصول المبكر مطلوب لتسجيل الدخول",
"unauthorized": "لم يتمكن Zero من تحميل بياناتك من مزود الطرف الثالث. يرجى المحاولة مرة أخرى."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "aquest correu",
"dropFiles": "Arrossega arxius per adjuntar",
"attachments": "Adjunts",
"attachmentCount": "{count, plural, =0 {adjunts} one {adjunt} other {adjunts}}",
"fileCount": "{count, plural, =0 {fitxers} one {fitxer} other {fitxers}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "adjunts",
"countPlural=one": "adjunt",
"countPlural=other": "adjunts"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "fitxers",
"countPlural=one": "fitxer",
"countPlural=other": "fitxers"
}
}
],
"saveDraft": "Desa l'esborrany",
"send": "Enviar",
"forward": "Reenviar"
@@ -226,7 +246,17 @@
"toSave": "per desar",
"label": "Etiqueta:",
"search": "Cerca notes...",
"noteCount": "{count, plural, =0 {Afegeix notes} one {# nota} other {# notes}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Afegeix notes",
"countPlural=one": "# nota",
"countPlural=other": "# notes"
}
}
],
"notePinned": "Nota fixada",
"noteUnpinned": "Nota desfixada",
"colorChanged": "Color de la nota actualitzat",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "No s'ha pogut actualitzar l'àlies predeterminat a la configuració de Gmail"
},
"mail": {
"replies": "{count, plural, =0 {respostes} one {# resposta} other {# respostes}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "respostes",
"countPlural=one": "# resposta",
"countPlural=other": "# respostes"
}
}
],
"deselectAll": "S'han desseleccionat tots els correus",
"selectedEmails": "S'han seleccionat {count} correus",
"noEmailsToSelect": "No hi ha correus per seleccionar",
@@ -602,4 +642,4 @@
"early_access_required": "Es requereix accés anticipat per iniciar sessió",
"unauthorized": "Zero no ha pogut carregar les teves dades del proveïdor extern. Si us plau, torna-ho a provar."
}
}
}

View File

@@ -162,8 +162,32 @@
"thisEmail": "tento email",
"dropFiles": "Přetáhněte sem soubory",
"attachments": "Přílohy",
"attachmentCount": "{count, plural, =0 {příloh} one {příloha} few {přílohy} many {příloh} other {příloh}}",
"fileCount": "{count, plural, =0 {souborů} one {soubor} few {soubory} many {souborů} other {souborů}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "příloh",
"countPlural=one": "příloha",
"countPlural=few": "přílohy",
"countPlural=many": "příloh",
"countPlural=other": "příloh"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "souborů",
"countPlural=one": "soubor",
"countPlural=few": "soubory",
"countPlural=many": "souborů",
"countPlural=other": "souborů"
}
}
],
"saveDraft": "Uložit koncept",
"send": "Odeslat",
"forward": "Přeposlat"
@@ -226,7 +250,19 @@
"toSave": "uložit",
"label": "Značka:",
"search": "Hledat poznámky...",
"noteCount": "{count, plural, =0 {Přidat poznámku} one {# poznámka} few {# poznámky} many {# poznámky} other {# poznámek}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Přidat poznámku",
"countPlural=one": "# poznámka",
"countPlural=few": "# poznámky",
"countPlural=many": "# poznámky",
"countPlural=other": "# poznámek"
}
}
],
"notePinned": "Poznámka připnuta",
"noteUnpinned": "Poznámka odepnuta",
"colorChanged": "Barva poznámky aktualizována",
@@ -279,7 +315,19 @@
"failedToUpdateGmailAlias": "Nepodařilo se aktualizovat výchozí alias v nastavení Gmailu"
},
"mail": {
"replies": "{count, plural, =0 {odpovědí} one {#odpověď} few {#odpovědi} many {#odpovědí} other {#odpovědí}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "odpovědí",
"countPlural=one": "#odpověď",
"countPlural=few": "#odpovědi",
"countPlural=many": "#odpovědí",
"countPlural=other": "#odpovědí"
}
}
],
"deselectAll": "Zrušit všechny emaily",
"selectedEmails": "Vybráno {count} emailů",
"noEmailsToSelect": "Žádné emaily k vybrání",
@@ -602,4 +650,4 @@
"early_access_required": "Pro přihlášení je vyžadován předběžný přístup",
"unauthorized": "Zero nemohl načíst vaše data od poskytovatele třetí strany. Zkuste to prosím znovu."
}
}
}

View File

@@ -1,4 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"common": {
"actions": {
"logout": "Abmelden",
@@ -162,8 +163,28 @@
"thisEmail": "Diese E-Mail",
"dropFiles": "Datei ablegen, um sie anzuhängen",
"attachments": "Anhänge",
"attachmentCount": "{count, plural, =0 {Anhänge} one {Anhang} other {Anhänge}}",
"fileCount": "{count, plural, =0 {Dateien} one {Datei} other {Dateien}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Anhänge",
"countPlural=one": "Anhang",
"countPlural=other": "Anhänge"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Dateien",
"countPlural=one": "Datei",
"countPlural=other": "Dateien"
}
}
],
"saveDraft": "Entwurf speichern",
"send": "Senden",
"forward": "Weiterleiten"
@@ -226,7 +247,17 @@
"toSave": "Um zu speichern",
"label": "Label:",
"search": "Notizen durchsuchen...",
"noteCount": "{count, plural, =0 {Füge Notizen hinzu} one {# Notiz} other {# Notizen}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Füge Notizen hinzu",
"countPlural=one": "# Notiz",
"countPlural=other": "# Notizen"
}
}
],
"notePinned": "Notiz angeheftet",
"noteUnpinned": "Notiz gelöst",
"colorChanged": "Notizfarbe aktualisiert",
@@ -279,7 +310,17 @@
"failedToUpdateGmailAlias": "Aktualisierung des Standard-Alias in den Gmail-Einstellungen fehlgeschlagen"
},
"mail": {
"replies": "{count, plural, =0 {Antworten} one {# Antwort} other {# Antworten}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Antworten",
"countPlural=one": "# Antwort",
"countPlural=other": "# Antworten"
}
}
],
"deselectAll": "Deselected all emails",
"selectedEmails": "{count} E-Mails ausgewählt",
"noEmailsToSelect": "Keine E-Mails zum Auswählen",
@@ -602,4 +643,4 @@
"early_access_required": "Für die Anmeldung ist ein Early-Access erforderlich",
"unauthorized": "Zero konnte deine Daten nicht vom Drittanbieter laden. Bitte versuche es erneut."
}
}
}

View File

@@ -1,4 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"common": {
"actions": {
"logout": "Logout",
@@ -162,8 +163,28 @@
"thisEmail": "this email",
"dropFiles": "Drop files to attach",
"attachments": "Attachments",
"attachmentCount": "{count, plural, =0 {attachments} one {attachment} other {attachments}}",
"fileCount": "{count, plural, =0 {files} one {file} other {files}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "attachments",
"countPlural=one": "attachment",
"countPlural=other": "attachments"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "files",
"countPlural=one": "file",
"countPlural=other": "files"
}
}
],
"saveDraft": "Save draft",
"send": "Send",
"forward": "Forward"
@@ -188,7 +209,7 @@
"cancel": "Cancel",
"goToWebsite": "Go to website",
"failedToUnsubscribe": "Failed to unsubscribe from mailing list",
"print":"Print"
"print": "Print"
},
"threadDisplay": {
"exitFullscreen": "Exit fullscreen",
@@ -208,7 +229,7 @@
"enableImages": "Show Images",
"star": "Star",
"unstar": "Unstar",
"printThread":"Print thread"
"printThread": "Print thread"
},
"notes": {
"title": "Notes",
@@ -226,7 +247,17 @@
"toSave": "to save",
"label": "Label:",
"search": "Search notes...",
"noteCount": "{count, plural, =0 {Add notes} one {# note} other {# notes}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Add notes",
"countPlural=one": "# note",
"countPlural=other": "# notes"
}
}
],
"notePinned": "Note pinned",
"noteUnpinned": "Note unpinned",
"colorChanged": "Note color updated",
@@ -279,7 +310,17 @@
"failedToUpdateGmailAlias": "Failed to update default alias in Gmail settings"
},
"mail": {
"replies": "{count, plural, =0 {replies} one {# reply} other {# replies}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "replies",
"countPlural=one": "# reply",
"countPlural=other": "# replies"
}
}
],
"deselectAll": "Deselected all emails",
"selectedEmails": "Selected {count} emails",
"noEmailsToSelect": "No emails to select",

View File

@@ -162,8 +162,28 @@
"thisEmail": "este correo electrónico",
"dropFiles": "Suelte los archivos para adjuntarlos",
"attachments": "Adjuntos",
"attachmentCount": "{count, plural, =0 {sin adjuntos} one {adjunto} other {adjuntos}}",
"fileCount": "{count, plural, =0 {archivos} one {archivo} other {archivos}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "sin adjuntos",
"countPlural=one": "adjunto",
"countPlural=other": "adjuntos"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "archivos",
"countPlural=one": "archivo",
"countPlural=other": "archivos"
}
}
],
"saveDraft": "Guardar borrador",
"send": "Enviar",
"forward": "Reenviar"
@@ -226,7 +246,17 @@
"toSave": "para guardar",
"label": "Etiqueta:",
"search": "Buscar notas...",
"noteCount": "{count, plural, =0 {Añade notas} one {# nota} other {# notas}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Añade notas",
"countPlural=one": "# nota",
"countPlural=other": "# notas"
}
}
],
"notePinned": "Nota anclada",
"noteUnpinned": "Nota desanclada",
"colorChanged": "Color de nota actualizado",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "No se pudo actualizar el alias predeterminado en la configuración de Gmail"
},
"mail": {
"replies": "{count, plural, =0 {respuestas} one {# respuesta} other {# respuestas}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "respuestas",
"countPlural=one": "# respuesta",
"countPlural=other": "# respuestas"
}
}
],
"deselectAll": "Deseleccionar todos los correos",
"selectedEmails": "{count} Correos seleccionados",
"noEmailsToSelect": "No hay correos para seleccionar",
@@ -602,4 +642,4 @@
"early_access_required": "Se requiere acceso anticipado para iniciar sesión",
"unauthorized": "Zero no pudo cargar tus datos del proveedor externo. Por favor, inténtalo de nuevo."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "این ایمیل",
"dropFiles": "فایل‌ها را برای پیوست کردن اینجا رها کنید",
"attachments": "پیوست‌ها",
"attachmentCount": "{count, plural, =0 {پیوست} one {پیوست} other {پیوست}}",
"fileCount": "{count, plural, =0 {فایل} one {فایل} other {فایل}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "پیوست",
"countPlural=one": "پیوست",
"countPlural=other": "پیوست"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "فایل",
"countPlural=one": "فایل",
"countPlural=other": "فایل"
}
}
],
"saveDraft": "ذخیره پیش‌نویس",
"send": "ارسال",
"forward": "ارسال به دیگران"
@@ -226,7 +246,17 @@
"toSave": "برای ذخیره",
"label": "برچسب:",
"search": "جستجوی یادداشت‌ها...",
"noteCount": "{count, plural, =0 {افزودن یادداشت‌ها} one {# یادداشت} other {# یادداشت}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "افزودن یادداشت‌ها",
"countPlural=one": "# یادداشت",
"countPlural=other": "# یادداشت"
}
}
],
"notePinned": "یادداشت سنجاق شد",
"noteUnpinned": "سنجاق یادداشت برداشته شد",
"colorChanged": "رنگ یادداشت به‌روزرسانی شد",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "به‌روزرسانی نام مستعار پیش‌فرض در تنظیمات جیمیل ناموفق بود"
},
"mail": {
"replies": "{count, plural, =0 {پاسخ‌ها} one {# پاسخ} other {# پاسخ}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "پاسخ‌ها",
"countPlural=one": "# پاسخ",
"countPlural=other": "# پاسخ"
}
}
],
"deselectAll": "همه ایمیل‌ها از حالت انتخاب خارج شدند",
"selectedEmails": "{count} ایمیل انتخاب شده",
"noEmailsToSelect": "ایمیلی برای انتخاب وجود ندارد",
@@ -602,4 +642,4 @@
"early_access_required": "برای ورود به سیستم، دسترسی زودهنگام لازم است",
"unauthorized": "Zero نتوانست داده‌های شما را از ارائه‌دهنده شخص ثالث بارگیری کند. لطفاً دوباره تلاش کنید."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "ce courriel",
"dropFiles": "Déposer les fichiers à joindre",
"attachments": "Pièces jointes",
"attachmentCount": "{count, plural, =0 {pièces jointes} one {pièce jointe} other {pièces jointes}}",
"fileCount": "{count, plural, =0 {fichiers} one {fichier} other {fichiers}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "pièces jointes",
"countPlural=one": "pièce jointe",
"countPlural=other": "pièces jointes"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "fichiers",
"countPlural=one": "fichier",
"countPlural=other": "fichiers"
}
}
],
"saveDraft": "Enregistrer le brouillon",
"send": "Envoyer",
"forward": "Transférer"
@@ -226,7 +246,17 @@
"toSave": "À enregistrer",
"label": "Étiquette :",
"search": "Rechercher des notes...",
"noteCount": "{count, plural, =0 {Ajoutez des notes} one {# note} other {# notes}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Ajoutez des notes",
"countPlural=one": "# note",
"countPlural=other": "# notes"
}
}
],
"notePinned": "Note épinglée",
"noteUnpinned": "Note supprimée des épingles",
"colorChanged": "Couleur de la note modifiée",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Échec de la mise à jour de l'alias par défaut dans les paramètres Gmail"
},
"mail": {
"replies": "{count, plural, =0 {réponses} one {# réponse} other {# réponses}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "réponses",
"countPlural=one": "# réponse",
"countPlural=other": "# réponses"
}
}
],
"deselectAll": "Courriels désélectionnés",
"selectedEmails": "{count} courriels sélectionnés",
"noEmailsToSelect": "Aucun courriel à sélectionner",
@@ -602,4 +642,4 @@
"early_access_required": "L'accès anticipé est requis pour se connecter",
"unauthorized": "Zero n'a pas pu charger vos données depuis le fournisseur tiers. Veuillez réessayer."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "यह ईमेल",
"dropFiles": "अटैच करने के लिए फाइलें यहां छोड़ें",
"attachments": "अटैचमेंटस",
"attachmentCount": "{count, plural, =0 {अटैचमेंटस} one {अटैचमेंट} other {अटैचमेंटस}}",
"fileCount": "{count, plural, =0 {फ़ाइलें} one {फाइल्} other {फ़ाइलें}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "अटैचमेंटस",
"countPlural=one": "अटैचमेंट",
"countPlural=other": "अटैचमेंटस"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "फ़ाइलें",
"countPlural=one": "फाइल्",
"countPlural=other": "फ़ाइलें"
}
}
],
"saveDraft": "ड्राफ्ट सेव करें",
"send": "भेजें",
"forward": "आगे भेजें"
@@ -226,7 +246,17 @@
"toSave": "सेव करने के लिए",
"label": "लेबल:",
"search": "नोट्स खोजें...",
"noteCount": "{count, plural, =0 {नोट्स जोड़ें} one {# नोट} other {# नोट्स}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "नोट्स जोड़ें",
"countPlural=one": "# नोट",
"countPlural=other": "# नोट्स"
}
}
],
"notePinned": "नोट पिन किया गया",
"noteUnpinned": "नोट अनपिन किया गया",
"colorChanged": "नोट का रंग अपडेट किया गया",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Gmail सेटिंग्स में डिफ़ॉल्ट उपनाम अपडेट करने में विफल"
},
"mail": {
"replies": "{count, plural, =0 {जवाब} one {# जवाब} other {# जवाब}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "जवाब",
"countPlural=one": "# जवाब",
"countPlural=other": "# जवाब"
}
}
],
"deselectAll": "सभी ईमेल अचयनित किए गए",
"selectedEmails": "{count} ईमेल चयनित किए गए",
"noEmailsToSelect": "चयन करने के लिए कोई ईमेल नहीं है",
@@ -602,4 +642,4 @@
"early_access_required": "लॉग इन करने के लिए अर्ली एक्सेस आवश्यक है",
"unauthorized": "Zero तृतीय पक्ष प्रदाता से आपका डेटा लोड नहीं कर सका। कृपया पुनः प्रयास करें।"
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "ez az e-mail",
"dropFiles": "Húzd ide a fájlokat a csatoláshoz",
"attachments": "Csatolmányok",
"attachmentCount": "{count, plural, =0 {csatolmány} one {csatolmány} other {csatolmány}}",
"fileCount": "{count, plural, =0 {fájl} one {fájl} other {fájl}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "csatolmány",
"countPlural=one": "csatolmány",
"countPlural=other": "csatolmány"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "fájl",
"countPlural=one": "fájl",
"countPlural=other": "fájl"
}
}
],
"saveDraft": "Piszkozat mentése",
"send": "Küldés",
"forward": "Továbbítás"
@@ -226,7 +246,17 @@
"toSave": "mentéshez",
"label": "Címke:",
"search": "Jegyzetek keresése...",
"noteCount": "{count, plural, =0 {Jegyzetek hozzáadása} one {# jegyzet} other {# jegyzet}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Jegyzetek hozzáadása",
"countPlural=one": "# jegyzet",
"countPlural=other": "# jegyzet"
}
}
],
"notePinned": "Jegyzet rögzítve",
"noteUnpinned": "Jegyzet rögzítése feloldva",
"colorChanged": "Jegyzet színe frissítve",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Nem sikerült frissíteni az alapértelmezett álnevet a Gmail beállításaiban"
},
"mail": {
"replies": "{count, plural, =0 {válasz} one {# válasz} other {# válasz}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "válasz",
"countPlural=one": "# válasz",
"countPlural=other": "# válasz"
}
}
],
"deselectAll": "Összes e-mail kijelölése megszüntetve",
"selectedEmails": "{count} e-mail kiválasztva",
"noEmailsToSelect": "Nincsenek kiválasztható e-mailek",
@@ -602,4 +642,4 @@
"early_access_required": "A bejelentkezéshez korai hozzáférés szükséges",
"unauthorized": "A Zero nem tudta betölteni az adataidat a harmadik féltől származó szolgáltatótól. Kérjük, próbáld újra."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "このメール",
"dropFiles": "添付するファイルをドロップ",
"attachments": "添付ファイル",
"attachmentCount": "{count, plural, =0 {attachments} other {attachments}}",
"fileCount": "{count, plural, =0 {files} other {files}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "添付ファイル",
"countPlural=one": "添付ファイル",
"countPlural=other": "添付ファイル"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "ファイル",
"countPlural=one": "ファイル",
"countPlural=other": "ファイル"
}
}
],
"saveDraft": "下書きを保存",
"send": "送信",
"forward": "転送"
@@ -226,7 +246,17 @@
"toSave": "保存する",
"label": "ラベル:",
"search": "メモを検索…",
"noteCount": "{count, plural, =0 {Add notes} one {# note} other {# notes}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Add notes",
"countPlural=one": "# note",
"countPlural=other": "# notes"
}
}
],
"notePinned": "メモをピン留めしました",
"noteUnpinned": "メモのピン留めを解除しました",
"colorChanged": "メモの色が更新されました",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Gmail設定でデフォルトのエイリアスの更新に失敗しました"
},
"mail": {
"replies": "{count, plural, =0 {replies} other {# replies}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "返信",
"countPlural=one": "返信 #個",
"countPlural=other": "返信 #個"
}
}
],
"deselectAll": "すべてのメールを選択解除しました",
"selectedEmails": "選択したメール {count} 件",
"noEmailsToSelect": "選択したメールはありません",
@@ -602,4 +642,4 @@
"early_access_required": "ログインするにはアーリーアクセスが必要です",
"unauthorized": "Zeroは第三者プロバイダーからデータを読み込めませんでした。もう一度お試しください。"
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "이 이메일",
"dropFiles": "첨부할 파일 끌어놓기",
"attachments": "첨부 파일",
"attachmentCount": "{count, plural, =0 {첨부 파일} other {첨부 파일}}",
"fileCount": "{count, plural, =0 {파일} other {파일}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "첨부 파일",
"countPlural=one": "첨부 파일",
"countPlural=other": "첨부 파일"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "파일",
"countPlural=one": "파일",
"countPlural=other": "파일"
}
}
],
"saveDraft": "임시 저장",
"send": "전송",
"forward": "전달"
@@ -226,7 +246,17 @@
"toSave": "저장하기",
"label": "라벨:",
"search": "노트 검색...",
"noteCount": "{count, plural, =0 {메모 추가} one {메모 #개} other {메모 #개}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "메모 추가",
"countPlural=one": "메모 #개",
"countPlural=other": "메모 #개"
}
}
],
"notePinned": "노트를 고정했습니다",
"noteUnpinned": "노트를 고정 해제했습니다",
"colorChanged": "노트 색상을 변경했습니다",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Gmail 설정에서 기본 별칭 업데이트에 실패했습니다"
},
"mail": {
"replies": "{count, plural, =0 {답장} one {답장 #개} other {답장 #개}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "답장",
"countPlural=one": "답장 #개",
"countPlural=other": "답장 #개"
}
}
],
"deselectAll": "모든 이메일 선택 해제됨",
"selectedEmails": "{count}개의 이메일 선택됨",
"noEmailsToSelect": "선택할 이메일이 없습니다",
@@ -602,4 +642,4 @@
"early_access_required": "로그인하려면 얼리 액세스가 필요합니다",
"unauthorized": "Zero가 제3자 제공업체에서 데이터를 불러올 수 없습니다. 다시 시도해 주세요."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "šis e-pasts",
"dropFiles": "Ievelciet failus, lai pievienotu",
"attachments": "Pielikumi",
"attachmentCount": "{count, plural, zero {}=0 {pielikumi} one {pielikums} other {pielikumi}}",
"fileCount": "{count, plural, zero {}=0 {faili} one {fails} other {faili}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "pielikumi",
"countPlural=one": "pielikums",
"countPlural=other": "pielikumi"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "faili",
"countPlural=one": "fails",
"countPlural=other": "faili"
}
}
],
"saveDraft": "Saglabāt melnrakstu",
"send": "Sūtīt",
"forward": "Pārsūtīt"
@@ -226,7 +246,17 @@
"toSave": "lai saglabātu",
"label": "Iezīme:",
"search": "Meklēt piezīmes...",
"noteCount": "{count, plural, zero {}=0 {Pievienot piezīmes} one {# piezīme} other {# piezīmes}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Pievienot piezīmes",
"countPlural=one": "# piezīme",
"countPlural=other": "# piezīmes"
}
}
],
"notePinned": "Piezīme piesprausta",
"noteUnpinned": "Piezīme atsprausta",
"colorChanged": "Piezīmes krāsa atjaunināta",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Neizdevās atjaunināt noklusējuma aizstājvārdu Gmail iestatījumos"
},
"mail": {
"replies": "{count, plural, zero {}=0 {atbildes} one {# atbilde} other {# atbildes}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "atbildes",
"countPlural=one": "# atbilde",
"countPlural=other": "# atbildes"
}
}
],
"deselectAll": "Atcelta visu e-pastu izvēle",
"selectedEmails": "Izvēlēti {count} e-pasti",
"noEmailsToSelect": "Nav e-pastu, ko izvēlēties",
@@ -602,4 +642,4 @@
"early_access_required": "Lai pieteiktos, nepieciešama agrīnā piekļuve",
"unauthorized": "Zero nevarēja ielādēt jūsu datus no trešās puses pakalpojuma sniedzēja. Lūdzu, mēģiniet vēlreiz."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "deze e-mail",
"dropFiles": "Sleep bestanden om bij te voegen",
"attachments": "Bijlagen",
"attachmentCount": "{count, plural, =0 {bijlagen} one {bijlage} other {bijlagen}}",
"fileCount": "{count, plural, =0 {bestanden} one {bestand} other {bestanden}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "bijlagen",
"countPlural=one": "bijlage",
"countPlural=other": "bijlagen"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "bestanden",
"countPlural=one": "bestand",
"countPlural=other": "bestanden"
}
}
],
"saveDraft": "Concept opslaan",
"send": "Verzenden",
"forward": "Doorsturen"
@@ -226,7 +246,17 @@
"toSave": "om op te slaan",
"label": "Label:",
"search": "Notities zoeken...",
"noteCount": "{count, plural, =0 {Notities toevoegen} one {# notitie} other {# notities}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Notities toevoegen",
"countPlural=one": "# notitie",
"countPlural=other": "# notities"
}
}
],
"notePinned": "Notitie vastgezet",
"noteUnpinned": "Notitie losgemaakt",
"colorChanged": "Notitie kleur bijgewerkt",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Kon standaardalias niet bijwerken in Gmail-instellingen"
},
"mail": {
"replies": "{count, plural, =0 {reacties} one {# reactie} other {# reacties}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "reacties",
"countPlural=one": "# reactie",
"countPlural=other": "# reacties"
}
}
],
"deselectAll": "Alle e-mails gedeselecteerd",
"selectedEmails": "{count} e-mails geselecteerd",
"noEmailsToSelect": "Geen e-mails om te selecteren",
@@ -602,4 +642,4 @@
"early_access_required": "Vroege toegang is vereist om in te loggen",
"unauthorized": "Zero kon je gegevens niet laden van de externe provider. Probeer het opnieuw."
}
}
}

View File

@@ -162,8 +162,32 @@
"thisEmail": "ten email",
"dropFiles": "Upuść pliki, aby załączyć",
"attachments": "Załączniki",
"attachmentCount": "{count, plural, =0 {załączników} one {załącznik} few {załączniki} many {załączników} other {załączników}}",
"fileCount": "{count, plural, =0 {plików} one {plik} few {pliki} many {plików} other {plików}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "załączników",
"countPlural=one": "załącznik",
"countPlural=few": "załączniki",
"countPlural=many": "załączników",
"countPlural=other": "załączników"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "plików",
"countPlural=one": "plik",
"countPlural=few": "pliki",
"countPlural=many": "plików",
"countPlural=other": "plików"
}
}
],
"saveDraft": "Zapisz wersję roboczą",
"send": "Wyślij",
"forward": "Przekaż dalej"
@@ -226,7 +250,19 @@
"toSave": "aby zapisać",
"label": "Etykieta:",
"search": "Szukaj notatek...",
"noteCount": "{count, plural, =0 {Dodaj notatki} one {# notatka} few {# notatki} many {# notatek} other {# notatek}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Dodaj notatki",
"countPlural=one": "# notatka",
"countPlural=few": "# notatki",
"countPlural=many": "# notatek",
"countPlural=other": "# notatek"
}
}
],
"notePinned": "Notatka przypięta",
"noteUnpinned": "Notatka odpięta",
"colorChanged": "Kolor notatki zaktualizowany",
@@ -279,7 +315,19 @@
"failedToUpdateGmailAlias": "Nie udało się zaktualizować domyślnego aliasu w ustawieniach Gmaila"
},
"mail": {
"replies": "{count, plural, =0 {odpowiedzi} one {# odpowiedź} few {# odpowiedzi} many {# odpowiedzi} other {# odpowiedzi}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "odpowiedzi",
"countPlural=one": "# odpowiedź",
"countPlural=few": "# odpowiedzi",
"countPlural=many": "# odpowiedzi",
"countPlural=other": "# odpowiedzi"
}
}
],
"deselectAll": "Odznaczono wszystkie e-maile",
"selectedEmails": "Zaznaczono {count} e-maili",
"noEmailsToSelect": "Brak e-maili do zaznaczenia",
@@ -602,4 +650,4 @@
"early_access_required": "Wymagany jest wczesny dostęp, aby się zalogować",
"unauthorized": "Zero nie mógł załadować Twoich danych od zewnętrznego dostawcy. Spróbuj ponownie."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "este e-mail",
"dropFiles": "Arraste e solte arquivos para anexar",
"attachments": "Anexos",
"attachmentCount": "{count, plural, =0 {anexos} one {anexo} other {anexos}}",
"fileCount": "{count, plural, =0 {arquivos} one {arquivo} other {arquivos}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "anexos",
"countPlural=one": "anexo",
"countPlural=other": "anexos"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "arquivos",
"countPlural=one": "arquivo",
"countPlural=other": "arquivos"
}
}
],
"saveDraft": "Salvar rascunho",
"send": "Enviar",
"forward": "Encaminhar"
@@ -226,7 +246,17 @@
"toSave": "para salvar",
"label": "Etiqueta:",
"search": "Pesquisar notas...",
"noteCount": "{count, plural, =0 {Adicione notas} one {# nota} other {# notas}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Adicione notas",
"countPlural=one": "# nota",
"countPlural=other": "# notas"
}
}
],
"notePinned": "Nota fixada",
"noteUnpinned": "Nota desfixada",
"colorChanged": "Cor da nota atualizada",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Falha ao atualizar o alias padrão nas configurações do Gmail"
},
"mail": {
"replies": "{count, plural, =0 {arquivos} one {arquivo} other {arquivos}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "arquivos",
"countPlural=one": "arquivo",
"countPlural=other": "arquivos"
}
}
],
"deselectAll": "Todos os e-mails desselecionados",
"selectedEmails": "{count} e-mails selecionados",
"noEmailsToSelect": "Não há e-mails para selecionar",
@@ -602,4 +642,4 @@
"early_access_required": "É necessário acesso antecipado para fazer login",
"unauthorized": "O Zero não conseguiu carregar seus dados do provedor terceirizado. Por favor, tente novamente."
}
}
}

View File

@@ -162,8 +162,32 @@
"thisEmail": "это письмо",
"dropFiles": "Поместите файлы, чтобы прикрепить",
"attachments": "Приложения",
"attachmentCount": "{count, plural, =0 {вложений} one {вложение} few {вложения} many {вложений} other {вложений}}",
"fileCount": "{count, plural, =0 {файлов} one {файл} few {файла} many {файлов} other {файла}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "вложений",
"countPlural=one": "вложение",
"countPlural=few": "вложения",
"countPlural=many": "вложений",
"countPlural=other": "вложений"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "файлов",
"countPlural=one": "файл",
"countPlural=few": "файла",
"countPlural=many": "файлов",
"countPlural=other": "файла"
}
}
],
"saveDraft": "Сохранить черновик",
"send": "Отправить",
"forward": "Переслать"
@@ -226,7 +250,19 @@
"toSave": "чтобы сохранить",
"label": "Метка:",
"search": "Найти заметки...",
"noteCount": "{count, plural, =0 {Добавьте заметки} one {# заметка} few {# заметки} many {# заметок} other {# заметки}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Добавьте заметки",
"countPlural=one": "# заметка",
"countPlural=few": "# заметки",
"countPlural=many": "# заметок",
"countPlural=other": "# заметки"
}
}
],
"notePinned": "Заметка закреплена",
"noteUnpinned": "Заметка откреплена",
"colorChanged": "Цвет заметки обновлён",
@@ -279,9 +315,32 @@
"failedToUpdateGmailAlias": "Не удалось обновить адрес по умолчанию в настройках Gmail"
},
"mail": {
"replies": "{count, plural, =0 {ответы} one {# ответ} few {# ответа} many {# ответов} other {# ответы}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "ответы",
"countPlural=one": "# ответ",
"countPlural=few": "# ответа",
"countPlural=many": "# ответов",
"countPlural=other": "# ответы"
}
}
],
"deselectAll": "Отменить выбор всех писем",
"selectedEmails": "{count, plural, one {Выбрано # письмо} few {Выбрано # письма} many {Выбрано # писем} other {Выбрано # письма}}",
"selectedEmails": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Выбрано # письмо",
"countPlural=few": "Выбрано # письма",
"countPlural=many": "Выбрано # писем",
"countPlural=other": "Выбрано # письма"
}
}
],
"noEmailsToSelect": "Нет писем для выбора",
"markedAsRead": "Отметить как прочитанное",
"markedAsUnread": "Отметить как непрочитанное",
@@ -602,4 +661,4 @@
"early_access_required": "Для входа требуется ранний доступ",
"unauthorized": "Zero не удалось загрузить ваши данные от стороннего поставщика. Пожалуйста, попробуйте снова."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "bu e-postaya",
"dropFiles": "Eklemek için dosyaları bırakın",
"attachments": "Ekler",
"attachmentCount": "{count, plural, =0 {ekler} one {ek} other {ekler}}",
"fileCount": "{count, plural, =0 {dosyalar} one {dosya} other {dosyalar}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "ekler",
"countPlural=one": "ek",
"countPlural=other": "ekler"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "dosyalar",
"countPlural=one": "dosya",
"countPlural=other": "dosyalar"
}
}
],
"saveDraft": "Taslağı kaydet",
"send": "Gönder",
"forward": "İlet"
@@ -226,7 +246,17 @@
"toSave": "kaydetmek",
"label": "Etiket:",
"search": "Notlarda ara...",
"noteCount": "{count, plural, =0 {Not ekle} one {# not} other {# notlar}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Not ekle",
"countPlural=one": "# not",
"countPlural=other": "# notlar"
}
}
],
"notePinned": "Not sabitlendi",
"noteUnpinned": "Notun sabitlenmesi iptal edildi",
"colorChanged": "Not rengi güncellendi",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Gmail ayarlarında varsayılan adres güncellenemedi"
},
"mail": {
"replies": "{count, plural, =0 {cevaplar} one {# cevap} other {# cevaplar}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "cevaplar",
"countPlural=one": "# cevap",
"countPlural=other": "# cevaplar"
}
}
],
"deselectAll": "Seçilen tüm postaları bırak",
"selectedEmails": "{count} adet posta seçildi",
"noEmailsToSelect": "Seçilecek posta yok",
@@ -602,4 +642,4 @@
"early_access_required": "Giriş yapmak için erken erişim gereklidir",
"unauthorized": "Zero, verilerinizi 3. taraf sağlayıcıdan yükleyemedi. Lütfen tekrar deneyin."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "email này",
"dropFiles": "Kéo thả tệp để đính kèm",
"attachments": "Tệp đính kèm",
"attachmentCount": "{count, plural, =0 {tệp đính kèm} one {tệp đính kèm} other {tệp đính kèm}}",
"fileCount": "{count, plural, =0 {tệp} one {tệp} other {tệp}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "tệp đính kèm",
"countPlural=one": "tệp đính kèm",
"countPlural=other": "tệp đính kèm"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "tệp",
"countPlural=one": "tệp",
"countPlural=other": "tệp"
}
}
],
"saveDraft": "Lưu bản nháp",
"send": "Gửi",
"forward": "Chuyển tiếp"
@@ -226,7 +246,17 @@
"toSave": "để lưu",
"label": "Nhãn:",
"search": "Tìm kiếm ghi chú...",
"noteCount": "{count, plural, =0 {Thêm ghi chú} one {# ghi chú} other {# ghi chú}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "Thêm ghi chú",
"countPlural=one": "# ghi chú",
"countPlural=other": "# ghi chú"
}
}
],
"notePinned": "Đã ghim ghi chú",
"noteUnpinned": "Đã bỏ ghim ghi chú",
"colorChanged": "Đã cập nhật màu ghi chú",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "Không thể cập nhật bí danh mặc định trong cài đặt Gmail"
},
"mail": {
"replies": "{count, plural, =0 {phản hồi} one {# phản hồi} other {# phản hồi}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "phản hồi",
"countPlural=one": "# phản hồi",
"countPlural=other": "# phản hồi"
}
}
],
"deselectAll": "Đã bỏ chọn tất cả email",
"selectedEmails": "Đã chọn {count} email",
"noEmailsToSelect": "Không có email nào để chọn",
@@ -602,4 +642,4 @@
"early_access_required": "Cần có quyền truy cập sớm để đăng nhập",
"unauthorized": "Zero không thể tải dữ liệu của bạn từ nhà cung cấp bên thứ 3. Vui lòng thử lại."
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "此邮件",
"dropFiles": "拖放文件以附加",
"attachments": "附件",
"attachmentCount": "{count, plural, =0 {附件} one {个附件} other {个附件}}",
"fileCount": "{count, plural, =0 {文件} one {个文件} other {个文件}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "附件",
"countPlural=one": "个附件",
"countPlural=other": "个附件"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "文件",
"countPlural=one": "个文件",
"countPlural=other": "个文件"
}
}
],
"saveDraft": "保存草稿",
"send": "发送",
"forward": "转发"
@@ -226,7 +246,17 @@
"toSave": "以保存",
"label": "标签:",
"search": "搜索笔记...",
"noteCount": "{count, plural, =0 {添加笔记} one {# 条笔记} other {# 条笔记}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "添加笔记",
"countPlural=one": "# 条笔记",
"countPlural=other": "# 条笔记"
}
}
],
"notePinned": "笔记已固定",
"noteUnpinned": "笔记已取消固定",
"colorChanged": "笔记颜色已更新",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "无法更新 Gmail 设置中的默认别名"
},
"mail": {
"replies": "{count, plural, =0 {回复} one {# 条回复} other {# 条回复}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "回复",
"countPlural=one": "# 条回复",
"countPlural=other": "# 条回复"
}
}
],
"deselectAll": "已取消选择所有邮件",
"selectedEmails": "已选择 {count} 封邮件",
"noEmailsToSelect": "没有可选择的邮件",
@@ -406,7 +446,7 @@
"zeroSignatureDescription": "在您的邮件中添加 Zero 签名。",
"defaultEmailAlias": "默认邮箱别名",
"selectDefaultEmail": "选择默认邮箱",
"defaultEmailDescription": "此邮箱将作为撰写新邮件时的默认发件人地址",
"defaultEmailDescription": "此邮箱将作为撰写新邮件时的默认\"发件人\"地址",
"autoRead": "自动阅读",
"autoReadDescription": "点击邮件时自动将其标记为已读。"
},
@@ -602,4 +642,4 @@
"early_access_required": "需要提前访问权限才能登录",
"unauthorized": "Zero 无法从第三方提供商加载您的数据。请重试。"
}
}
}

View File

@@ -162,8 +162,28 @@
"thisEmail": "此郵件",
"dropFiles": "拖放檔案以附加",
"attachments": "附件",
"attachmentCount": "{count, plural, =0 {附件} one {附件} other {附件}}",
"fileCount": "{count, plural, =0 {檔案} one {檔案} other {檔案}}",
"attachmentCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "附件",
"countPlural=one": "附件",
"countPlural=other": "附件"
}
}
],
"fileCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "檔案",
"countPlural=one": "檔案",
"countPlural=other": "檔案"
}
}
],
"saveDraft": "儲存草稿",
"send": "傳送",
"forward": "轉寄"
@@ -226,7 +246,17 @@
"toSave": "以儲存",
"label": "標籤:",
"search": "搜尋筆記...",
"noteCount": "{count, plural, =0 {添加筆記} one {# 則筆記} other {# 則筆記}}",
"noteCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "添加筆記",
"countPlural=one": "# 則筆記",
"countPlural=other": "# 則筆記"
}
}
],
"notePinned": "筆記已釘選",
"noteUnpinned": "筆記已取消釘選",
"colorChanged": "筆記顏色已更新",
@@ -279,7 +309,17 @@
"failedToUpdateGmailAlias": "無法更新 Gmail 設定中的預設別名"
},
"mail": {
"replies": "{count, plural, =0 {回覆} one {# 則回覆} other {# 則回覆}}",
"replies": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=0": "回覆",
"countPlural=one": "# 則回覆",
"countPlural=other": "# 則回覆"
}
}
],
"deselectAll": "已取消選取所有郵件",
"selectedEmails": "已選取 {count} 封郵件",
"noEmailsToSelect": "沒有可選取的郵件",
@@ -602,4 +642,4 @@
"early_access_required": "需要搶先體驗權限才能登入",
"unauthorized": "Zero 無法從第三方提供者載入您的數據。請再試一次。"
}
}
}

View File

@@ -9,7 +9,8 @@
"start": "wrangler dev --port 3000 --show-interactive-dev-session=false",
"types": "wrangler types",
"deploy": "wrangler deploy",
"lint": "eslint ."
"lint": "eslint .",
"machine-translate": "inlang machine translate --project project.inlang"
},
"dependencies": {
"@ai-sdk/perplexity": "1.1.9",
@@ -31,19 +32,19 @@
"@tanstack/query-sync-storage-persister": "^5.75.0",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-persist-client": "^5.75.2",
"@tiptap/core": "2.11.5",
"@tiptap/extension-bold": "2.11.5",
"@tiptap/extension-document": "2.11.5",
"@tiptap/extension-file-handler": "2.22.3",
"@tiptap/extension-image": "2.22.3",
"@tiptap/extension-link": "2.11.5",
"@tiptap/extension-paragraph": "2.11.5",
"@tiptap/extension-placeholder": "2.11.5",
"@tiptap/extension-text": "2.11.5",
"@tiptap/html": "2.11.5",
"@tiptap/pm": "2.11.5",
"@tiptap/react": "2.11.5",
"@tiptap/starter-kit": "2.11.5",
"@tiptap/core": "2.23.0",
"@tiptap/extension-bold": "2.23.0",
"@tiptap/extension-document": "2.23.0",
"@tiptap/extension-file-handler": "2.23.0",
"@tiptap/extension-image": "2.23.0",
"@tiptap/extension-link": "2.23.0",
"@tiptap/extension-paragraph": "2.23.0",
"@tiptap/extension-placeholder": "2.23.0",
"@tiptap/extension-text": "2.23.0",
"@tiptap/html": "2.23.0",
"@tiptap/pm": "2.23.0",
"@tiptap/react": "2.23.0",
"@tiptap/starter-kit": "2.23.0",
"@trpc/client": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
@@ -108,7 +109,6 @@
"tiptap-extension-auto-joiner": "0.1.3",
"tiptap-extension-global-drag-handle": "0.1.18",
"tiptap-markdown": "0.8.10",
"use-intl": "^4.1.0",
"vaul": "1.1.2",
"virtua": "0.41.2",
"vite-plugin-babel": "1.3.1",
@@ -137,6 +137,8 @@
"typescript": "catalog:",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4",
"wrangler": "catalog:"
"wrangler": "catalog:",
"@inlang/paraglide-js": "2.1.0",
"@inlang/cli": "^3.0.0"
}
}

1
apps/mail/project.inlang/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cache

View File

@@ -0,0 +1 @@
Js9jATlrYooiz9OitC

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": [
"ar",
"ca",
"cs",
"de",
"es",
"fr",
"hi",
"nl",
"ja",
"ko",
"lv",
"pl",
"pt",
"ru",
"tr",
"hu",
"fa",
"vi",
"zh_CN",
"zh_TW"
],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -28,7 +28,7 @@ function createIDBPersister(idbValidKey: IDBValidKey = 'zero-query-cache') {
} satisfies Persister;
}
export const makeQueryClient = (connectionId: string | null) =>
export const makeQueryClient = () =>
new QueryClient({
queryCache: new QueryCache({
onError: (err, { meta }) => {
@@ -50,7 +50,7 @@ export const makeQueryClient = (connectionId: string | null) =>
queries: {
retry: false,
refetchOnWindowFocus: false,
queryKeyHashFn: (queryKey) => hashKey([{ connectionId }, ...queryKey]),
queryKeyHashFn: (queryKey) => hashKey([{ connectionId: 'default' }, ...queryKey]),
gcTime: 1000 * 60 * 60 * 24,
},
mutations: {
@@ -67,13 +67,12 @@ let browserQueryClient = {
activeConnectionId: string | null;
};
const getQueryClient = (connectionId: string | null) => {
const getQueryClient = () => {
if (typeof window === 'undefined') {
return makeQueryClient(connectionId);
return makeQueryClient();
} else {
if (!browserQueryClient.queryClient || browserQueryClient.activeConnectionId !== connectionId) {
browserQueryClient.queryClient = makeQueryClient(connectionId);
browserQueryClient.activeConnectionId = connectionId;
if (!browserQueryClient.queryClient) {
browserQueryClient.queryClient = makeQueryClient();
}
return browserQueryClient.queryClient;
}
@@ -104,15 +103,9 @@ export const trpcClient = createTRPCClient<AppRouter>({
type TrpcHook = ReturnType<typeof useTRPC>;
export function QueryProvider({
children,
connectionId,
}: PropsWithChildren<{ connectionId: string | null }>) {
const persister = useMemo(
() => createIDBPersister(`zero-query-cache-${connectionId ?? 'default'}`),
[connectionId],
);
const queryClient = useMemo(() => getQueryClient(connectionId), [connectionId]);
export function QueryProvider({ children }: PropsWithChildren) {
const persister = useMemo(() => createIDBPersister(`zero-query-cache-default`), []);
const queryClient = useMemo(() => getQueryClient(), []);
return (
<PersistQueryClientProvider

View File

@@ -1,20 +1,11 @@
import type { IntlMessages, Locale } from '@/i18n/config';
import { QueryProvider } from './query-provider';
import { AutumnProvider } from 'autumn-js/react';
import type { PropsWithChildren } from 'react';
import { IntlProvider } from 'use-intl';
export function ServerProviders({
children,
messages,
locale,
connectionId,
}: PropsWithChildren<{ messages: IntlMessages; locale: Locale; connectionId: string | null }>) {
export function ServerProviders({ children }: PropsWithChildren) {
return (
<AutumnProvider backendUrl={import.meta.env.VITE_PUBLIC_BACKEND_URL}>
<IntlProvider messages={messages} locale={locale} timeZone={'UTC'}>
<QueryProvider connectionId={connectionId}>{children}</QueryProvider>
</IntlProvider>
<QueryProvider>{children}</QueryProvider>
</AutumnProvider>
);
}

View File

@@ -1,7 +1,7 @@
import type { Config } from '@react-router/dev/config';
export default {
ssr: true,
ssr: false,
buildDirectory: 'build',
prerender: ['/manifest.webmanifest'],
future: {

View File

@@ -1,3 +1,4 @@
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { cloudflare } from '@cloudflare/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import tsconfigPaths from 'vite-tsconfig-paths';
@@ -12,7 +13,7 @@ const ReactCompilerConfig = {
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: 'ssr' } }),
cloudflare(),
reactRouter(),
babel({
filter: /\.[jt]sx?$/,
@@ -39,12 +40,16 @@ export default defineConfig({
});
},
},
paraglideVitePlugin({
project: './project.inlang',
outdir: './paraglide',
strategy: ['cookie', 'baseLocale'],
}),
],
server: {
port: 3000,
warmup: {
clientFiles: ['./app/**/*', './components/**/*'],
ssrFiles: ['./app/**/*', './components/**/*'],
},
},
css: {
@@ -52,11 +57,11 @@ export default defineConfig({
plugins: [tailwindcss()],
},
},
ssr: {
optimizeDeps: {
include: ['novel', '@tiptap/extension-placeholder'],
},
},
// ssr: {
// optimizeDeps: {
// include: ['novel', '@tiptap/extension-placeholder'],
// },
// },
build: {
sourcemap: false,
},

View File

@@ -1,39 +0,0 @@
import { createRequestHandler } from 'react-router';
declare global {
interface CloudflareEnvironment extends Env {}
}
declare module 'react-router' {
export interface AppLoadContext {
cloudflare: {
env: CloudflareEnvironment;
ctx: ExecutionContext;
};
}
}
const requestHandler = createRequestHandler(
// @ts-ignore, virtual module
() => import('virtual:react-router/server-build'),
import.meta.env.MODE,
);
const sentryUrl = `https://o4509328786915328.ingest.us.sentry.io/api/4509328795303936/envelope/?sentry_version=7&sentry_key=03f6397c0eb458bf1e37c4776a31797c&sentry_client=sentry.javascript.react%2F9.19.0`;
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname.startsWith('/monitoring')) {
const sentryRequest = new Request(sentryUrl, {
method: request.method,
headers: request.headers,
body: request.body,
});
return await fetch(sentryRequest);
}
return requestHandler(request, {
cloudflare: { env, ctx },
});
},
} satisfies ExportedHandler<CloudflareEnvironment>;

View File

@@ -1,26 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "zero",
"compatibility_date": "2025-05-01",
"compatibility_flags": ["nodejs_compat"],
"main": "./worker.ts",
"compatibility_date": "2025-06-27",
"observability": {
"enabled": true,
},
"assets": {
"directory": "./build/client/",
"not_found_handling": "single-page-application",
"html_handling": "force-trailing-slash",
},
"env": {
"local": {
"name": "zero-local",
"vars": {
"VITE_PUBLIC_BACKEND_URL": "http://localhost:8787",
"VITE_PUBLIC_APP_URL": "http://localhost:3000",
},
},
"staging": {
"name": "zero-staging",
"vars": {
"VITE_PUBLIC_BACKEND_URL": "https://sapi.0.email",
"VITE_PUBLIC_APP_URL": "https://staging.0.email",
},
},
"production": {
"name": "zero-production",
"vars": {
"VITE_PUBLIC_BACKEND_URL": "https://api.0.email",
"VITE_PUBLIC_APP_URL": "https://0.email",

View File

@@ -1,7 +1,6 @@
import { COOKIE_CONSENT_KEY, type CookiePreferences } from '../../lib/cookies';
import { privateProcedure, publicProcedure, router } from '../trpc';
import { getCookie, setCookie } from 'hono/cookie';
import { env } from 'cloudflare:workers';
import { privateProcedure, router } from '../trpc';
import type { Context } from 'hono';
import { z } from 'zod';
@@ -56,12 +55,4 @@ export const cookiePreferencesRouter = router({
setCookie(ctx.c, COOKIE_CONSENT_KEY, JSON.stringify(newPreferences));
return newPreferences;
}),
setLocaleCookie: publicProcedure
.input(z.object({ locale: z.string() }))
.mutation(async ({ ctx, input }) => {
setCookie(ctx.c, 'i18n:locale', input.locale, {
domain: env.COOKIE_DOMAIN,
});
return { success: true };
}),
});

View File

@@ -28,7 +28,7 @@
},
"buckets": {
"json": {
"include": ["apps/mail/locales/[locale].json"]
"include": ["apps/mail/messages/[locale].json"]
}
}
}

3412
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff