mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-30 07:46:15 +00:00
Merge pull request #1056 from Mail-0/new-labels-design-mail-list
feat: new labels
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/components/ui/form';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { SettingsCard } from '@/components/settings/settings-card';
|
||||
import { LabelDialog } from '@/components/labels/label-dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CurvedArrow } from '@/components/icons/icons';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -42,21 +43,14 @@ export default function LabelsPage() {
|
||||
const { data: labels, isLoading, error, refetch } = useLabels();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingLabel, setEditingLabel] = useState<LabelType | null>(null);
|
||||
const form = useForm<LabelType>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
color: { backgroundColor: '#E2E2E2', textColor: '#000000' },
|
||||
},
|
||||
});
|
||||
|
||||
const trpc = useTRPC();
|
||||
const { mutateAsync: createLabel } = useMutation(trpc.labels.create.mutationOptions());
|
||||
const { mutateAsync: updateLabel } = useMutation(trpc.labels.update.mutationOptions());
|
||||
const { mutateAsync: deleteLabel } = useMutation(trpc.labels.delete.mutationOptions());
|
||||
|
||||
const formColor = form.watch('color');
|
||||
|
||||
const onSubmit = async (data: LabelType) => {
|
||||
toast.promise(
|
||||
const handleSubmit = async (data: LabelType) => {
|
||||
await toast.promise(
|
||||
editingLabel
|
||||
? updateLabel({ id: editingLabel.id!, name: data.name, color: data.color })
|
||||
: createLabel({ color: data.color, name: data.name }),
|
||||
@@ -64,10 +58,6 @@ export default function LabelsPage() {
|
||||
loading: 'Saving label...',
|
||||
success: 'Label saved successfully',
|
||||
error: 'Failed to save label',
|
||||
finally: async () => {
|
||||
await refetch();
|
||||
handleClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -83,135 +73,33 @@ export default function LabelsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = async (label: LabelType) => {
|
||||
const handleEdit = (label: LabelType) => {
|
||||
setEditingLabel(label);
|
||||
form.reset({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDialogOpen(false);
|
||||
setEditingLabel(null);
|
||||
form.reset({
|
||||
name: '',
|
||||
color: { backgroundColor: '#E2E2E2', textColor: '#000000' },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<SettingsCard
|
||||
title={t('pages.settings.labels.title')}
|
||||
description={t('pages.settings.labels.description')}
|
||||
action={
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => setEditingLabel(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Label
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<div className="container mx-auto max-w-[750px]">
|
||||
<DialogContent showOverlay={true}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingLabel ? 'Edit Label' : 'Create New Label'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Label Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter label name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Label>Color</Label>
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
{[
|
||||
// Row 1 - Grayscale
|
||||
'#000000',
|
||||
'#434343',
|
||||
'#666666',
|
||||
'#999999',
|
||||
'#cccccc',
|
||||
'#ffffff',
|
||||
// Row 2 - Warm colors
|
||||
'#fb4c2f',
|
||||
'#ffad47',
|
||||
'#fad165',
|
||||
'#ff7537',
|
||||
'#cc3a21',
|
||||
'#8a1c0a',
|
||||
// Row 3 - Cool colors
|
||||
'#16a766',
|
||||
'#43d692',
|
||||
'#4a86e8',
|
||||
'#285bac',
|
||||
'#3c78d8',
|
||||
'#0d3472',
|
||||
// Row 4 - Purple tones
|
||||
'#a479e2',
|
||||
'#b99aff',
|
||||
'#653e9b',
|
||||
'#3d188e',
|
||||
'#f691b3',
|
||||
'#994a64',
|
||||
// Row 5 - Pastels
|
||||
'#f6c5be',
|
||||
'#ffe6c7',
|
||||
'#c6f3de',
|
||||
'#c9daf8',
|
||||
].map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`h-10 w-10 rounded-[4px] border-[0.5px] border-white/10 ${
|
||||
formColor?.backgroundColor === color ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() =>
|
||||
form.setValue('color', {
|
||||
backgroundColor: color,
|
||||
textColor: '#ffffff',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button className="h-8" type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="h-8 [&_svg]:size-4" onClick={form.handleSubmit(onSubmit)}>
|
||||
{editingLabel ? 'Save Changes' : 'Create Label'}
|
||||
<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]" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
</Dialog>
|
||||
</form>
|
||||
</Form>
|
||||
<LabelDialog
|
||||
trigger={
|
||||
<Button onClick={() => setEditingLabel(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Label
|
||||
</Button>
|
||||
}
|
||||
editingLabel={editingLabel}
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) setEditingLabel(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onSuccess={refetch}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
|
||||
170
apps/mail/components/labels/label-dialog.tsx
Normal file
170
apps/mail/components/labels/label-dialog.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { CurvedArrow } from '@/components/icons/icons';
|
||||
import { LABEL_COLORS } from '@/lib/label-colors';
|
||||
import type { Label as LabelType } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Command } from 'lucide-react';
|
||||
|
||||
interface LabelDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: () => void;
|
||||
editingLabel?: LabelType | null;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSubmit: (data: LabelType) => Promise<void>;
|
||||
}
|
||||
|
||||
export function LabelDialog({
|
||||
trigger,
|
||||
onSuccess,
|
||||
editingLabel,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: LabelDialogProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isControlled = open !== undefined;
|
||||
const dialogOpen = isControlled ? open : isOpen;
|
||||
const setDialogOpen = isControlled ? onOpenChange! : setIsOpen;
|
||||
|
||||
const form = useForm<LabelType>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
color: { backgroundColor: '#E2E2E2', textColor: '#000000' },
|
||||
},
|
||||
});
|
||||
|
||||
const formColor = form.watch('color');
|
||||
|
||||
// Reset form when editingLabel changes or dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
if (editingLabel) {
|
||||
form.reset({
|
||||
name: editingLabel.name,
|
||||
color: editingLabel.color || { backgroundColor: '#E2E2E2', textColor: '#000000' },
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
color: { backgroundColor: '#E2E2E2', textColor: '#000000' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [dialogOpen, editingLabel, form]);
|
||||
|
||||
const handleSubmit = async (data: LabelType) => {
|
||||
await onSubmit(data);
|
||||
handleClose();
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setDialogOpen(false);
|
||||
form.reset({
|
||||
name: '',
|
||||
color: { backgroundColor: '#E2E2E2', textColor: '#000000' },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
<div className="container mx-auto max-w-[750px]">
|
||||
<DialogContent showOverlay={true}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingLabel ? 'Edit Label' : 'Create New Label'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(handleSubmit)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Label Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter label name" {...field} autoFocus />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Label>Color</Label>
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
{LABEL_COLORS.map((color, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={`h-10 w-10 rounded-[4px] border-[0.5px] border-white/10 transition-all ${
|
||||
formColor?.backgroundColor === color.backgroundColor &&
|
||||
formColor?.textColor === color.textColor
|
||||
? 'scale-110 ring-2 ring-blue-500'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color.backgroundColor }}
|
||||
onClick={() =>
|
||||
form.setValue('color', {
|
||||
backgroundColor: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button className="h-8" type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="h-8 [&_svg]:size-4" type="submit">
|
||||
{editingLabel ? 'Save Changes' : 'Create Label'}
|
||||
<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]" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -197,6 +197,7 @@ type FolderProps = {
|
||||
isSelect?: boolean;
|
||||
onFolderClick?: (id: string) => void;
|
||||
hasChildren?: boolean;
|
||||
color?: number;
|
||||
} & FolderComponentProps;
|
||||
|
||||
const Folder = forwardRef<HTMLDivElement, FolderProps & React.HTMLAttributes<HTMLDivElement>>(
|
||||
@@ -211,6 +212,7 @@ const Folder = forwardRef<HTMLDivElement, FolderProps & React.HTMLAttributes<HTM
|
||||
children,
|
||||
onFolderClick,
|
||||
hasChildren,
|
||||
color,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -231,7 +233,7 @@ const Folder = forwardRef<HTMLDivElement, FolderProps & React.HTMLAttributes<HTM
|
||||
<Accordion.Item {...props} value={value} className="relative h-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
`hover:bg-black/10 flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm dark:hover:bg-[#202020]`,
|
||||
`flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm hover:bg-black/10 dark:hover:bg-[#202020]`,
|
||||
className,
|
||||
{
|
||||
'bg-sidebar-accent rounded-md': isSelect && isSelectable,
|
||||
@@ -265,11 +267,11 @@ const Folder = forwardRef<HTMLDivElement, FolderProps & React.HTMLAttributes<HTM
|
||||
</Accordion.Trigger>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Bookmark className="relative mr-3 size-4" />
|
||||
<Bookmark className={cn(`relative mr-3 size-4 text-[${color}]`)} />
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn('flex-1 truncate ', {
|
||||
className={cn('max-w-[124px] flex-1 truncate', {
|
||||
'cursor-pointer': canExpand && isSelectable && onFolderClick,
|
||||
'font-bold': isSelect,
|
||||
})}
|
||||
@@ -288,15 +290,15 @@ const Folder = forwardRef<HTMLDivElement, FolderProps & React.HTMLAttributes<HTM
|
||||
>
|
||||
{element}
|
||||
</span>
|
||||
{count > 0 &&
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto shrink-0 rounded-full px-2 py-0.5 text-xs font-medium text-muted-foreground bg-transparent'
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto shrink-0 rounded-full bg-transparent px-2 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<Accordion.Content className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down relative h-full overflow-hidden text-sm">
|
||||
{element && indicator && <TreeIndicator aria-hidden="true" />}
|
||||
|
||||
@@ -425,9 +425,15 @@ const Thread = memo(
|
||||
{highlightText(latestMessage.subject, searchValue.highlight)}
|
||||
</p>
|
||||
)}
|
||||
<div className="hidden md:flex">
|
||||
{/* <div className="hidden md:flex">
|
||||
{getThreadData.labels ? <MailLabels labels={getThreadData.labels} /> : null}
|
||||
</div>
|
||||
</div> */}
|
||||
{threadLabels && (
|
||||
<div className="mr-0 flex w-full items-center justify-end gap-1">
|
||||
{!isFolderSent ? <RenderLabels labels={threadLabels} /> : null}
|
||||
{/* {getThreadData.labels ? <MailLabels labels={getThreadData.labels} /> : null} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{emailContent && (
|
||||
<div className="text-muted-foreground mt-2 line-clamp-2 text-xs">
|
||||
@@ -444,16 +450,6 @@ const Thread = memo(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{threadLabels && (
|
||||
<div className="ml-[47px] flex w-full items-center justify-between gap-1 px-4">
|
||||
{!isFolderSent ? (
|
||||
<span className="mt-0.5 items-center space-x-2">
|
||||
<RenderLabels labels={threadLabels} />
|
||||
</span>
|
||||
) : null}
|
||||
{/* {getThreadData.labels ? <MailLabels labels={getThreadData.labels} /> : null} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -38,11 +38,15 @@ export const RenderLabels = ({ count = 1, labels }: { count?: number; labels: La
|
||||
key={label.id}
|
||||
onClick={handleFilterByLabel(label)}
|
||||
className={cn(
|
||||
'dark:bg-subtleBlack bg-subtleWhite text-primary inline-block overflow-hidden truncate rounded border px-1.5 py-0.5 text-xs font-medium',
|
||||
'dark:bg-subtleBlack bg-subtleWhite text-primary inline-block overflow-hidden truncate rounded bg-opacity-10 px-1.5 py-0.5 text-xs font-medium',
|
||||
searchValue.value.includes(`label:${label.name}`) &&
|
||||
'border-neutral-800 dark:border-white',
|
||||
)}
|
||||
style={{ backgroundColor: label.color?.backgroundColor, color: label.color?.textColor }}
|
||||
style={{
|
||||
background: label.color?.backgroundColor + '1a',
|
||||
color: label.color?.backgroundColor,
|
||||
// borderColor: label.color?.backgroundColor,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</button>
|
||||
@@ -50,7 +54,7 @@ export const RenderLabels = ({ count = 1, labels }: { count?: number; labels: La
|
||||
{hiddenLabels.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="text-muted-foreground dark:bg-subtleBlack bg-subtleWhite inline-block truncate rounded border px-1.5 py-0.5 text-xs font-medium overflow-hidden">
|
||||
<button className="text-muted-foreground dark:bg-subtleBlack bg-subtleWhite inline-block overflow-hidden truncate rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
+{hiddenLabels.length}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -60,13 +64,14 @@ export const RenderLabels = ({ count = 1, labels }: { count?: number; labels: La
|
||||
key={label.id}
|
||||
onClick={handleFilterByLabel(label)}
|
||||
className={cn(
|
||||
'dark:bg-subtleBlack bg-subtleWhite inline-block truncate rounded border px-1.5 py-0.5 text-xs font-medium overflow-hidden',
|
||||
'dark:bg-subtleBlack bg-subtleWhite inline-block overflow-hidden truncate rounded border px-1.5 py-0.5 text-xs font-medium',
|
||||
searchValue.value.includes(`label:${label.name}`) &&
|
||||
'border-neutral-800 dark:border-white',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: label.color?.backgroundColor,
|
||||
color: label.color?.textColor,
|
||||
borderColor: label.color?.backgroundColor,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
|
||||
@@ -13,17 +13,13 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from './sidebar';
|
||||
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 { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { useSidebar } from '../context/sidebar-context';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { RecursiveFolder } from './recursive-folder';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -35,6 +31,7 @@ import { useSession } from '@/lib/auth-client';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
import SidebarLabels from './sidebar-labels';
|
||||
import { CurvedArrow } from '../icons/icons';
|
||||
import { Command, Plus } from 'lucide-react';
|
||||
import { Tree } from '../magicui/file-tree';
|
||||
@@ -42,6 +39,7 @@ import { useCallback, useRef } from 'react';
|
||||
import { BASE_URL } from '@/lib/constants';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { Label } from '@/types';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
@@ -78,23 +76,16 @@ export function NavMain({ items }: NavMainProps) {
|
||||
const pathname = location.pathname;
|
||||
const searchParams = new URLSearchParams();
|
||||
const [category] = useQueryState('category');
|
||||
const [searchValue, setSearchValue] = useSearchValue();
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
|
||||
const { data: session } = useSession();
|
||||
const { data: connections } = useConnections();
|
||||
const { data: stats } = useStats();
|
||||
const { data: activeConnection } = useActiveConnection();
|
||||
const form = useForm<LabelType>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
color: { backgroundColor: '', textColor: '#ffffff' },
|
||||
},
|
||||
});
|
||||
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { mutateAsync: createLabel } = useMutation(trpc.labels.create.mutationOptions());
|
||||
const formColor = form.watch('color');
|
||||
|
||||
const { data, refetch } = useLabels();
|
||||
|
||||
@@ -172,11 +163,6 @@ export function NavMain({ items }: NavMainProps) {
|
||||
[pathname, category, searchParams, isValidInternalUrl],
|
||||
);
|
||||
|
||||
const getLabelCount = useCallback((labelName: string | undefined): number => {
|
||||
if (!stats || !labelName) return 0;
|
||||
return stats.find((stat) => stat.label?.toLowerCase() === labelName.toLowerCase())?.count ?? 0;
|
||||
}, [stats]);
|
||||
|
||||
const activeAccount = React.useMemo(() => {
|
||||
if (!activeConnection?.id || !connections?.connections) return null;
|
||||
return connections.connections.find((connection) => connection.id === activeConnection?.id);
|
||||
@@ -204,54 +190,11 @@ export function NavMain({ items }: NavMainProps) {
|
||||
[pathname, searchParams],
|
||||
);
|
||||
|
||||
const handleFilterByLabel = (label: LabelType) => () => {
|
||||
const existingValue = searchValue.value;
|
||||
if (existingValue.includes(`label:${label.name}`)) {
|
||||
setSearchValue({
|
||||
value: existingValue.replace(`label:${label.name}`, ''),
|
||||
highlight: '',
|
||||
folder: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const newValue = existingValue ? `${existingValue} label:${label.name}` : `label:${label.name}`;
|
||||
setSearchValue({
|
||||
value: newValue,
|
||||
highlight: '',
|
||||
folder: '',
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (data: LabelType) => {
|
||||
if (!data.color?.backgroundColor) {
|
||||
form.setError('color', {
|
||||
type: 'required',
|
||||
message: 'Please select a color',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.promise(createLabel(data), {
|
||||
loading: 'Creating label...',
|
||||
success: 'Label created successfully',
|
||||
error: 'Failed to create label',
|
||||
finally: () => {
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating label:', error);
|
||||
} finally {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDialogOpen(false);
|
||||
form.reset({
|
||||
name: '',
|
||||
color: { backgroundColor: '', textColor: '#ffffff' },
|
||||
await toast.promise(createLabel(data), {
|
||||
loading: 'Creating label...',
|
||||
success: 'Label created successfully',
|
||||
error: 'Failed to create label',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -297,8 +240,8 @@ export function NavMain({ items }: NavMainProps) {
|
||||
{activeAccount?.providerId === 'google' ? 'Labels' : 'Folders'}
|
||||
</span>
|
||||
{activeAccount?.providerId === 'google' ? (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<LabelDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -306,236 +249,18 @@ export function NavMain({ items }: NavMainProps) {
|
||||
>
|
||||
<Plus className="h-3 w-3 text-[#6D6D6D] dark:text-[#898989]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent showOverlay={true}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Label</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Label Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter label name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="color"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Color</FormLabel>
|
||||
<FormControl>
|
||||
<div className="w-full">
|
||||
<div className="bg-panelLight dark:bg-panelDark grid grid-cols-7 gap-4">
|
||||
{[
|
||||
// Row 1 - Grayscale
|
||||
'#000000',
|
||||
'#434343',
|
||||
'#666666',
|
||||
'#999999',
|
||||
'#cccccc',
|
||||
'#ffffff',
|
||||
// Row 2 - Warm colors
|
||||
'#fb4c2f',
|
||||
'#ffad47',
|
||||
'#fad165',
|
||||
'#ff7537',
|
||||
'#cc3a21',
|
||||
'#8a1c0a',
|
||||
// Row 3 - Cool colors
|
||||
'#16a766',
|
||||
'#43d692',
|
||||
'#4a86e8',
|
||||
'#285bac',
|
||||
'#3c78d8',
|
||||
'#0d3472',
|
||||
// Row 4 - Purple tones
|
||||
'#a479e2',
|
||||
'#b99aff',
|
||||
'#653e9b',
|
||||
'#3d188e',
|
||||
'#f691b3',
|
||||
'#994a64',
|
||||
// Row 5 - Pastels
|
||||
'#f6c5be',
|
||||
'#ffe6c7',
|
||||
'#c6f3de',
|
||||
'#c9daf8',
|
||||
].map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`h-10 w-10 rounded-[4px] border-[0.5px] border-white/10 ${
|
||||
field.value?.backgroundColor === color
|
||||
? 'ring-2 ring-blue-500'
|
||||
: ''
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() =>
|
||||
form.setValue('color', {
|
||||
backgroundColor: color,
|
||||
textColor: '#ffffff',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
className="h-8"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="h-8" type="submit">
|
||||
Create Label
|
||||
<div className="gap- flex h-5 items-center justify-center rounded-sm bg-white/10 px-1 dark:bg-black/10">
|
||||
<Command className="h-2 w-2 text-white dark:text-[#929292]" />
|
||||
<CurvedArrow className="mt-1.5 h-3 w-3 fill-white dark:fill-[#929292]" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
onSuccess={refetch}
|
||||
/>
|
||||
) : activeAccount?.providerId === 'microsoft' ? null : null}
|
||||
</div>
|
||||
|
||||
<div className="mr-0 flex-1 pr-0">
|
||||
<div className="bg-background no-scrollbar relative -m-2 max-h-48 flex-1 overflow-auto">
|
||||
<Tree className="bg-background rounded-md">
|
||||
{(() => {
|
||||
if (!data) return null;
|
||||
const isMicrosoftAccount = activeAccount?.providerId === 'microsoft';
|
||||
if (isMicrosoftAccount) {
|
||||
return data?.map((label) => (
|
||||
<RecursiveFolder
|
||||
key={label.id}
|
||||
label={label}
|
||||
activeAccount={activeAccount}
|
||||
count={getLabelCount(label.name)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const groups = {
|
||||
brackets: [] as typeof data,
|
||||
other: [] as typeof data,
|
||||
folders: {} as Record<string, typeof data>,
|
||||
};
|
||||
|
||||
data.forEach((label) => {
|
||||
if (/\[.*\]/.test(label.name)) {
|
||||
groups.brackets.push(label);
|
||||
} else if (/[^/]+\/[^/]+/.test(label.name)) {
|
||||
const [groupName] = label.name.split('/') as [string];
|
||||
if (!groups.folders[groupName]) {
|
||||
groups.folders[groupName] = [];
|
||||
}
|
||||
groups.folders[groupName].push(label);
|
||||
} else {
|
||||
groups.other.push(label);
|
||||
}
|
||||
});
|
||||
|
||||
const components = [];
|
||||
|
||||
Object.entries(groups.folders)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.forEach(([groupName, labels]) => {
|
||||
const groupFolder = {
|
||||
id: `group-${groupName}`,
|
||||
name: groupName,
|
||||
labels: labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name.split('/').slice(1).join('/'),
|
||||
originalLabel: label,
|
||||
})),
|
||||
};
|
||||
components.push(
|
||||
<RecursiveFolder
|
||||
key={groupFolder.id}
|
||||
label={groupFolder}
|
||||
activeAccount={activeAccount}
|
||||
count={getLabelCount(groupFolder.name)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
if (groups.other.length > 0) {
|
||||
groups.other.forEach((label) => {
|
||||
components.push(
|
||||
<RecursiveFolder
|
||||
key={label.id}
|
||||
label={{
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
originalLabel: label,
|
||||
}}
|
||||
count={getLabelCount(label.name)}
|
||||
activeAccount={activeAccount}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.brackets.length > 0) {
|
||||
const bracketsFolder = {
|
||||
id: 'group-other',
|
||||
name: 'Other',
|
||||
labels: groups.brackets.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name.replace(/\[|\]/g, ''),
|
||||
originalLabel: label,
|
||||
})),
|
||||
};
|
||||
components.push(
|
||||
<RecursiveFolder
|
||||
key={bracketsFolder.id}
|
||||
label={bracketsFolder}
|
||||
activeAccount={activeAccount}
|
||||
count={stats ? stats.find((stat) => stat.label?.toLowerCase() === bracketsFolder.name?.toLowerCase())?.count : 0}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return components;
|
||||
})()}
|
||||
</Tree>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarLabels
|
||||
data={data ?? []}
|
||||
activeAccount={activeAccount ?? null}
|
||||
stats={stats}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useActiveConnection, useConnections } from '@/hooks/use-connections';
|
||||
import { LabelSidebarContextMenu } from '../context/label-sidebar-context';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import type { Label, Label as LabelType } from '@/types';
|
||||
import { useSidebar } from '../context/sidebar-context';
|
||||
import type { Label as LabelType } from '@/types';
|
||||
import { Folder } from '../magicui/file-tree';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useCallback } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
export const RecursiveFolder = ({ label, activeAccount, count }: { label: any; activeAccount?: any; count?: number }) => {
|
||||
export const RecursiveFolder = ({
|
||||
label,
|
||||
activeAccount,
|
||||
count,
|
||||
}: {
|
||||
label: Label & { originalLabel?: Label };
|
||||
activeAccount?: any;
|
||||
count?: number;
|
||||
}) => {
|
||||
const [searchValue, setSearchValue] = useSearchValue();
|
||||
const isActive = searchValue.value.includes(`label:${label.name}`);
|
||||
const isFolderActive = isActive || window.location.pathname.includes(`/mail/label/${label.id}`);
|
||||
const navigate = useNavigate();
|
||||
const { data: connections } = useConnections();
|
||||
const { data: activeConnection } = useActiveConnection();
|
||||
const { setOpenMobile, isMobile } = useSidebar();
|
||||
|
||||
const handleFilterByLabel = useCallback(
|
||||
@@ -48,7 +54,7 @@ export const RecursiveFolder = ({ label, activeAccount, count }: { label: any; a
|
||||
return;
|
||||
}
|
||||
|
||||
const labelToUse = label.originalLabel || label;
|
||||
const labelToUse = label;
|
||||
|
||||
if (activeAccount.providerId === 'microsoft') {
|
||||
navigate(`/mail/${id}`);
|
||||
@@ -78,10 +84,16 @@ export const RecursiveFolder = ({ label, activeAccount, count }: { label: any; a
|
||||
hasChildren={hasChildren}
|
||||
onFolderClick={handleFolderClick}
|
||||
isSelect={isFolderActive}
|
||||
count={count}
|
||||
count={count || 0}
|
||||
className="max-w-[192px]"
|
||||
>
|
||||
{label.labels?.map((childLabel: any) => (
|
||||
<RecursiveFolder key={childLabel.id} label={childLabel} count={count} />
|
||||
<RecursiveFolder
|
||||
key={childLabel.id}
|
||||
label={childLabel}
|
||||
activeAccount={activeAccount}
|
||||
count={count}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</LabelSidebarContextMenu>
|
||||
|
||||
157
apps/mail/components/ui/sidebar-labels.tsx
Normal file
157
apps/mail/components/ui/sidebar-labels.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { RecursiveFolder } from './recursive-folder';
|
||||
import type { Label as LabelType } from '@/types';
|
||||
import { Tree } from '../magicui/file-tree';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type Props = {
|
||||
data: LabelType[];
|
||||
activeAccount:
|
||||
| {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
picture: string | null;
|
||||
createdAt: Date;
|
||||
providerId: 'google' | 'microsoft';
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
stats:
|
||||
| {
|
||||
count?: number;
|
||||
label?: string;
|
||||
}[]
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const SidebarLabels = ({ data, activeAccount, stats }: Props) => {
|
||||
const getLabelCount = useCallback(
|
||||
(labelName: string | undefined): number => {
|
||||
if (!stats || !labelName) return 0;
|
||||
return (
|
||||
stats.find((stat) => stat.label?.toLowerCase() === labelName.toLowerCase())?.count ?? 0
|
||||
);
|
||||
},
|
||||
[stats],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mr-0 flex-1 pr-0">
|
||||
<div className="bg-background no-scrollbar relative -m-2 flex-1 overflow-auto">
|
||||
<Tree className="bg-background rounded-md">
|
||||
{(() => {
|
||||
if (!data) return null;
|
||||
const isMicrosoftAccount = activeAccount?.providerId === 'microsoft';
|
||||
if (isMicrosoftAccount) {
|
||||
return data?.map((label) => (
|
||||
<RecursiveFolder
|
||||
key={label.id}
|
||||
label={label}
|
||||
activeAccount={activeAccount}
|
||||
count={getLabelCount(label.name)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const groups = {
|
||||
brackets: [] as typeof data,
|
||||
other: [] as typeof data,
|
||||
folders: {} as Record<string, typeof data>,
|
||||
};
|
||||
|
||||
data.forEach((label) => {
|
||||
if (/\[.*\]/.test(label.name)) {
|
||||
groups.brackets.push(label);
|
||||
} else if (/[^/]+\/[^/]+/.test(label.name)) {
|
||||
const [groupName] = label.name.split('/') as [string];
|
||||
if (!groups.folders[groupName]) {
|
||||
groups.folders[groupName] = [];
|
||||
}
|
||||
groups.folders[groupName].push(label);
|
||||
} else {
|
||||
groups.other.push(label);
|
||||
}
|
||||
});
|
||||
|
||||
const components = [];
|
||||
|
||||
Object.entries(groups.folders)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.forEach(([groupName, labels]) => {
|
||||
const groupFolder = {
|
||||
id: `group-${groupName}`,
|
||||
name: groupName,
|
||||
type: 'folder',
|
||||
labels: labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name.split('/').slice(1).join('/'),
|
||||
type: label.type,
|
||||
originalLabel: label,
|
||||
})),
|
||||
};
|
||||
components.push(
|
||||
<RecursiveFolder
|
||||
key={groupFolder.id}
|
||||
label={groupFolder}
|
||||
activeAccount={activeAccount}
|
||||
count={getLabelCount(groupFolder.name)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
if (groups.other.length > 0) {
|
||||
groups.other.forEach((label) => {
|
||||
components.push(
|
||||
<RecursiveFolder
|
||||
key={label.id}
|
||||
label={{
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
type: label.type,
|
||||
originalLabel: label,
|
||||
}}
|
||||
count={getLabelCount(label.name)}
|
||||
activeAccount={activeAccount}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.brackets.length > 0) {
|
||||
const bracketsFolder = {
|
||||
id: 'group-other',
|
||||
name: 'Other',
|
||||
type: 'folder',
|
||||
labels: groups.brackets.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name.replace(/\[|\]/g, ''),
|
||||
type: label.type,
|
||||
originalLabel: label,
|
||||
})),
|
||||
};
|
||||
components.push(
|
||||
<RecursiveFolder
|
||||
key={bracketsFolder.id}
|
||||
label={bracketsFolder}
|
||||
activeAccount={activeAccount}
|
||||
count={
|
||||
stats
|
||||
? stats.find(
|
||||
(stat) =>
|
||||
stat.label?.toLowerCase() === bracketsFolder.name?.toLowerCase(),
|
||||
)?.count
|
||||
: 0
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return components;
|
||||
})()}
|
||||
</Tree>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarLabels;
|
||||
@@ -260,7 +260,7 @@ const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'di
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
'scrollbar-none scrollbar-w-0 flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
47
apps/mail/lib/label-colors.ts
Normal file
47
apps/mail/lib/label-colors.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const LABEL_COLORS = [
|
||||
// Row 1 - Grayscale
|
||||
{ backgroundColor: '#000000', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#434343', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#666666', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#999999', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#cccccc', textColor: '#000000' },
|
||||
{ backgroundColor: '#ffffff', textColor: '#000000' },
|
||||
// Row 2 - Warm colors
|
||||
{ backgroundColor: '#fb4c2f', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#ffad47', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#fad165', textColor: '#000000' },
|
||||
{ backgroundColor: '#ff7537', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#cc3a21', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#8a1c0a', textColor: '#ffffff' },
|
||||
// Row 3 - Cool colors
|
||||
{ backgroundColor: '#16a766', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#43d692', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#4a86e8', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#285bac', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#3c78d8', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#0d3472', textColor: '#ffffff' },
|
||||
// Row 4 - Purple tones
|
||||
{ backgroundColor: '#a479e2', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#b99aff', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#653e9b', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#3d188e', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#f691b3', textColor: '#ffffff' },
|
||||
{ backgroundColor: '#994a64', textColor: '#ffffff' },
|
||||
// Row 5 - Pastels
|
||||
{ backgroundColor: '#f6c5be', textColor: '#000000' },
|
||||
{ backgroundColor: '#ffe6c7', textColor: '#000000' },
|
||||
{ backgroundColor: '#c6f3de', textColor: '#000000' },
|
||||
{ backgroundColor: '#c9daf8', textColor: '#000000' },
|
||||
] as const;
|
||||
|
||||
export type LabelColor = (typeof LABEL_COLORS)[number];
|
||||
|
||||
export function isValidLabelColor(color: { backgroundColor: string; textColor: string }): boolean {
|
||||
return LABEL_COLORS.some(
|
||||
(labelColor) =>
|
||||
labelColor.backgroundColor === color.backgroundColor &&
|
||||
labelColor.textColor === color.textColor,
|
||||
);
|
||||
}
|
||||
|
||||
export const LABEL_BACKGROUND_COLORS = LABEL_COLORS.map((color) => color.backgroundColor);
|
||||
@@ -6,6 +6,7 @@ export type Label = {
|
||||
textColor: string;
|
||||
};
|
||||
type: string;
|
||||
labels?: Label[];
|
||||
};
|
||||
|
||||
export interface User {
|
||||
|
||||
82
apps/server/src/lib/driver/google-label-color-map.ts
Normal file
82
apps/server/src/lib/driver/google-label-color-map.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
type ColorMapping = {
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
export const GOOGLE_LABEL_COLOR_MAP: Record<string, ColorMapping> = {
|
||||
// TODO: Add your custom color mappings here
|
||||
// Example format:
|
||||
// '#ffffff|#000000': { backgroundColor: '#yourCustomBg', textColor: '#yourCustomText' },
|
||||
|
||||
// Grayscale
|
||||
'#000000|#ffffff': { backgroundColor: '#000000', textColor: '#ffffff' },
|
||||
'#434343|#ffffff': { backgroundColor: '#434343', textColor: '#ffffff' },
|
||||
'#666666|#ffffff': { backgroundColor: '#666666', textColor: '#ffffff' },
|
||||
'#999999|#ffffff': { backgroundColor: '#999999', textColor: '#ffffff' },
|
||||
'#cccccc|#000000': { backgroundColor: '#cccccc', textColor: '#000000' },
|
||||
'#ffffff|#000000': { backgroundColor: '#ffffff', textColor: '#000000' },
|
||||
|
||||
// Warm colors
|
||||
'#fb4c2f|#ffffff': { backgroundColor: '#fb4c2f', textColor: '#ffffff' },
|
||||
'#ffad47|#ffffff': { backgroundColor: '#ffad47', textColor: '#ffffff' },
|
||||
'#fad165|#000000': { backgroundColor: '#fad165', textColor: '#000000' },
|
||||
'#ff7537|#ffffff': { backgroundColor: '#ff7537', textColor: '#ffffff' },
|
||||
'#cc3a21|#ffffff': { backgroundColor: '#cc3a21', textColor: '#ffffff' },
|
||||
'#8a1c0a|#ffffff': { backgroundColor: '#8a1c0a', textColor: '#ffffff' },
|
||||
|
||||
// Cool colors
|
||||
'#16a766|#ffffff': { backgroundColor: '#16a766', textColor: '#ffffff' },
|
||||
'#43d692|#ffffff': { backgroundColor: '#43d692', textColor: '#ffffff' },
|
||||
'#4a86e8|#ffffff': { backgroundColor: '#4a86e8', textColor: '#ffffff' },
|
||||
'#285bac|#ffffff': { backgroundColor: '#285bac', textColor: '#ffffff' },
|
||||
'#3c78d8|#ffffff': { backgroundColor: '#3c78d8', textColor: '#ffffff' },
|
||||
'#0d3472|#ffffff': { backgroundColor: '#0d3472', textColor: '#ffffff' },
|
||||
|
||||
// Purple tones
|
||||
'#a479e2|#ffffff': { backgroundColor: '#a479e2', textColor: '#ffffff' },
|
||||
'#b99aff|#ffffff': { backgroundColor: '#b99aff', textColor: '#ffffff' },
|
||||
'#653e9b|#ffffff': { backgroundColor: '#653e9b', textColor: '#ffffff' },
|
||||
'#3d188e|#ffffff': { backgroundColor: '#3d188e', textColor: '#ffffff' },
|
||||
'#f691b3|#ffffff': { backgroundColor: '#f691b3', textColor: '#ffffff' },
|
||||
'#994a64|#ffffff': { backgroundColor: '#994a64', textColor: '#ffffff' },
|
||||
|
||||
// Pastels
|
||||
'#f6c5be|#000000': { backgroundColor: '#f6c5be', textColor: '#000000' },
|
||||
'#ffe6c7|#000000': { backgroundColor: '#ffe6c7', textColor: '#000000' },
|
||||
'#c6f3de|#000000': { backgroundColor: '#c6f3de', textColor: '#000000' },
|
||||
'#c9daf8|#000000': { backgroundColor: '#c9daf8', textColor: '#000000' },
|
||||
};
|
||||
export function mapGoogleLabelColor(
|
||||
googleColor: ColorMapping | undefined,
|
||||
): ColorMapping | undefined {
|
||||
if (!googleColor || !googleColor.backgroundColor || !googleColor.textColor) {
|
||||
return googleColor;
|
||||
}
|
||||
|
||||
const key = `${googleColor.backgroundColor}|${googleColor.textColor}`;
|
||||
const mappedColor = GOOGLE_LABEL_COLOR_MAP[key];
|
||||
|
||||
return mappedColor || googleColor;
|
||||
}
|
||||
|
||||
export function mapToGoogleLabelColor(
|
||||
customColor: ColorMapping | undefined,
|
||||
): ColorMapping | undefined {
|
||||
if (!customColor || !customColor.backgroundColor || !customColor.textColor) {
|
||||
return customColor;
|
||||
}
|
||||
|
||||
for (const [googleKey, mappedValue] of Object.entries(GOOGLE_LABEL_COLOR_MAP)) {
|
||||
if (
|
||||
mappedValue.backgroundColor === customColor.backgroundColor &&
|
||||
mappedValue.textColor === customColor.textColor
|
||||
) {
|
||||
const parts = googleKey.split('|');
|
||||
const backgroundColor = parts[0] || '';
|
||||
const textColor = parts[1] || '';
|
||||
return { backgroundColor, textColor };
|
||||
}
|
||||
}
|
||||
|
||||
return customColor;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sanitizeContext,
|
||||
StandardizedError,
|
||||
} from './utils';
|
||||
import { mapGoogleLabelColor, mapToGoogleLabelColor } from './google-label-color-map';
|
||||
import { parseAddressList, parseFrom, wasSentWithTLS } from '../email-utils';
|
||||
import type { IOutgoingMessage, Label, ParsedMessage } from '../../types';
|
||||
import { sanitizeTipTapHtml } from '../sanitize-tip-tap-html';
|
||||
@@ -16,8 +17,8 @@ import { type gmail_v1, gmail } from '@googleapis/gmail';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import type { CreateDraftData } from '../schemas';
|
||||
import { createMimeMessage } from 'mimetext';
|
||||
import { cleanSearchValue } from '../utils';
|
||||
import { people } from '@googleapis/people';
|
||||
import { cleanSearchValue } from '../utils';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import * as he from 'he';
|
||||
|
||||
@@ -574,10 +575,10 @@ export class GoogleMailManager implements MailManager {
|
||||
id: label.id ?? '',
|
||||
name: label.name ?? '',
|
||||
type: label.type ?? '',
|
||||
color: {
|
||||
color: mapGoogleLabelColor({
|
||||
backgroundColor: label.color?.backgroundColor ?? '',
|
||||
textColor: label.color?.textColor ?? '',
|
||||
},
|
||||
}),
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
@@ -589,10 +590,10 @@ export class GoogleMailManager implements MailManager {
|
||||
return {
|
||||
id: labelId,
|
||||
name: res.data.name ?? '',
|
||||
color: {
|
||||
color: mapGoogleLabelColor({
|
||||
backgroundColor: res.data.color?.backgroundColor ?? '',
|
||||
textColor: res.data.color?.textColor ?? '',
|
||||
},
|
||||
}),
|
||||
type: res.data.type ?? 'user',
|
||||
};
|
||||
}
|
||||
@@ -607,10 +608,10 @@ export class GoogleMailManager implements MailManager {
|
||||
labelListVisibility: 'labelShow',
|
||||
messageListVisibility: 'show',
|
||||
color: label.color
|
||||
? {
|
||||
? mapToGoogleLabelColor({
|
||||
backgroundColor: label.color.backgroundColor,
|
||||
textColor: label.color.textColor,
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
@@ -622,10 +623,10 @@ export class GoogleMailManager implements MailManager {
|
||||
requestBody: {
|
||||
name: label.name,
|
||||
color: label.color
|
||||
? {
|
||||
? mapToGoogleLabelColor({
|
||||
backgroundColor: label.color.backgroundColor,
|
||||
textColor: label.color.textColor,
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user