Merge pull request #1056 from Mail-0/new-labels-design-mail-list

feat: new labels
This commit is contained in:
Adam
2025-05-24 11:44:10 -07:00
committed by GitHub
13 changed files with 559 additions and 473 deletions

View File

@@ -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">

View 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>
);
}

View File

@@ -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" />}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
)}

View File

@@ -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>

View 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;

View File

@@ -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}

View 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);

View File

@@ -6,6 +6,7 @@ export type Label = {
textColor: string;
};
type: string;
labels?: Label[];
};
export interface User {

View 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;
}

View File

@@ -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,
},
});