Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 152 additions & 163 deletions apps/mail/app/(routes)/settings/categories/page.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,9 @@
import { useSettings } from '@/hooks/use-settings';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { SettingsCard } from '@/components/settings/settings-card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { useState, useEffect, useCallback } from 'react';
import { useTRPC } from '@/providers/query-provider';
import { toast } from 'sonner';
import type { CategorySetting } from '@/hooks/use-categories';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import * as Icons from '@/components/icons/icons';
import { Sparkles } from '@/components/icons/icons';
import { Loader, GripVertical } from 'lucide-react';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
DndContext,
closestCenter,
Expand All @@ -29,15 +12,24 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { SettingsCard } from '@/components/settings/settings-card';
import type { CategorySetting } from '@/hooks/use-categories';
import { useState, useEffect, useCallback } from 'react';
import { useTRPC } from '@/providers/query-provider';
import { Sparkles } from '@/components/icons/icons';
import { Loader, GripVertical } from 'lucide-react';
import { useSettings } from '@/hooks/use-settings';
import type { DragEndEvent } from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useSortable } from '@dnd-kit/sortable';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { CSS } from '@dnd-kit/utilities';
import { toast } from 'sonner';
import React from 'react';

interface SortableCategoryItemProps {
Expand All @@ -63,14 +55,9 @@ const SortableCategoryItem = React.memo(function SortableCategoryItem({
handleFieldChange,
toggleDefault,
}: SortableCategoryItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: cat.id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: cat.id,
});

const style = {
transform: CSS.Transform.toString(transform),
Expand All @@ -81,123 +68,128 @@ const SortableCategoryItem = React.memo(function SortableCategoryItem({
<div
ref={setNodeRef}
style={style}
className={`rounded-lg border border-border bg-card p-4 shadow-sm ${
isDragging ? 'opacity-50 scale-95' : ''
className={`border-border bg-card h-36 rounded-lg border shadow-sm ${
isDragging ? 'scale-95 opacity-50' : ''
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted/50 transition-colors"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
<Badge variant="outline" className="text-xs font-normal bg-background">
{cat.id}
</Badge>
{cat.isDefault && (
<Badge className="bg-blue-500/10 text-blue-500 border-blue-200 text-xs">
Default
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Switch
id={`default-${cat.id}`}
checked={!!cat.isDefault}
onCheckedChange={() => toggleDefault(cat.id)}
/>
<Label htmlFor={`default-${cat.id}`} className="text-xs font-normal cursor-pointer">
Set as Default
</Label>
<div className="flex h-full w-full items-center">
<div
{...attributes}
{...listeners}
className="hover:bg-muted/50 bg-accent flex h-full w-14 cursor-grab items-center justify-center rounded-md rounded-r-none p-1 transition-colors active:cursor-grabbing"
aria-label="Drag to reorder"
>
<GripVertical className="text-muted-foreground size-4" />
</div>
</div>
<div className="flex h-full w-full flex-col gap-2 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs font-normal">
{cat.id}
</Badge>
{cat.isDefault && (
<Badge className="border-blue-500/40 bg-blue-500/10 text-xs text-blue-500">
Default
</Badge>
)}
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-2 py-1.5">
<Switch
id={`default-${cat.id}`}
className="data-[state=unchecked]:bg-muted-foreground/60"
checked={!!cat.isDefault}
onCheckedChange={() => toggleDefault(cat.id)}
/>
<Label htmlFor={`default-${cat.id}`} className="cursor-pointer text-xs font-normal">
Set as Default
</Label>
</div>
</div>

<div className="grid grid-cols-12 gap-4 items-start">
<div className="col-span-12 sm:col-span-6">
<Label className="text-xs mb-1.5 block">Display Name</Label>
<Input
className="h-8 text-sm"
value={cat.name}
onChange={(e) => handleFieldChange(cat.id, 'name', e.target.value)}
/>
</div>

<div className="col-span-12 sm:col-span-6">
<Label className="text-xs mb-1.5 block">Search Query</Label>
<div className="relative">
<Input
className="pr-8 h-8 text-sm font-mono"
value={cat.searchValue}
onChange={(e) => handleFieldChange(cat.id, 'searchValue', e.target.value)}
/>
<div className="grid grid-cols-12 items-start gap-4">
<div className="bg-accent/40 col-span-12 rounded-lg p-2 sm:col-span-6">
<Label className="mb-2 block text-xs">Display Name</Label>
<Input
className="bg-muted-foreground/10 h-8 text-sm"
value={cat.name}
onChange={(e) => handleFieldChange(cat.id, 'name', e.target.value)}
/>
</div>

<Popover
open={isActiveAi}
onOpenChange={(open) => {
if (open) {
setActiveAiCat(cat.id);
} else {
setActiveAiCat(null);
}
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 bg-background hover:bg-secondary rounded-full p-1"
aria-label="Generate search query with AI"
>
{isGeneratingQuery && isActiveAi ? (
<Loader className="h-3 w-3 animate-spin" />
) : (
<Sparkles className="h-3 w-3 fill-[#8B5CF6]" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3 space-y-3" sideOffset={4} align="end">
<div className="space-y-1">
<Label className="text-xs">Natural Language Query</Label>
<Input
className="h-8 text-sm"
placeholder="Describe the emails to include…"
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
/>
</div>
<div className="text-xs text-muted-foreground">
Example: "emails that mention quarterly reports"
</div>
<Button
size="sm"
className="w-full"
disabled={!promptValue.trim() || isGeneratingQuery}
onClick={async () => {
const prompt = promptValue.trim();
if (!prompt) return;
try {
const res = await generateSearchQuery({ query: prompt });
handleFieldChange(cat.id, 'searchValue', res.query);
toast.success('Search query generated');
<div className="bg-accent/40 col-span-12 rounded-lg p-2 sm:col-span-6">
<Label className="mb-2 block text-xs">Search Query</Label>
<div className="relative">
<Input
className="bg-muted-foreground/10 h-8 pr-8 font-mono text-sm"
value={cat.searchValue}
onChange={(e) => handleFieldChange(cat.id, 'searchValue', e.target.value)}
/>

<Popover
open={isActiveAi}
onOpenChange={(open) => {
if (open) {
setActiveAiCat(cat.id);
} else {
setActiveAiCat(null);
} catch (err) {
console.error(err);
toast.error('Failed to generate query');
}
}}
>
{isGeneratingQuery && isActiveAi ? (
<Loader className="h-3 w-3 animate-spin mr-1" />
) : (
<Sparkles className="h-3 w-3 fill-white mr-1" />
)}
Generate Query
</Button>
</PopoverContent>
</Popover>
<PopoverTrigger asChild>
<button
type="button"
className="bg-background hover:bg-secondary absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1"
aria-label="Generate search query with AI"
>
{isGeneratingQuery && isActiveAi ? (
<Loader className="h-3 w-3 animate-spin" />
) : (
<Sparkles className="h-3 w-3 fill-[#8B5CF6]" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 space-y-3 p-3" sideOffset={4} align="end">
<div className="space-y-1">
<Label className="text-xs">Natural Language Query</Label>
<Input
className="h-8 text-sm"
placeholder="Describe the emails to include…"
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
/>
</div>
<div className="text-muted-foreground text-xs">
Example: &quot;emails that mention quarterly reports&quot;
</div>
<Button
size="sm"
className="w-full"
disabled={!promptValue.trim() || isGeneratingQuery}
onClick={async () => {
const prompt = promptValue.trim();
if (!prompt) return;
try {
const res = await generateSearchQuery({ query: prompt });
handleFieldChange(cat.id, 'searchValue', res.query);
toast.success('Search query generated');
setActiveAiCat(null);
} catch (err) {
console.error(err);
toast.error('Failed to generate query');
Comment on lines +176 to +178
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling should include setActiveAiCat(null) after the error toast to ensure the popover closes when an error occurs, matching the behavior in the success path. Currently, if query generation fails, the popover remains open, creating an inconsistent user experience.

Suggested change
} catch (err) {
console.error(err);
toast.error('Failed to generate query');
} catch (err) {
console.error(err);
toast.error('Failed to generate query');
setActiveAiCat(null);
}

Spotted by Diamond

Is this helpful? React 👍 or 👎 to let us know.

}
}}
>
{isGeneratingQuery && isActiveAi ? (
<Loader className="mr-1 h-3 w-3 animate-spin" />
) : (
<Sparkles className="mr-1 h-3 w-3 fill-white" />
)}
Generate Query
</Button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -234,14 +226,11 @@ export default function CategoriesSettingsPage() {
}),
);

const toggleDefault = useCallback(
(id: string) => {
setCategories((prev) =>
prev.map((c) => ({ ...c, isDefault: c.id === id ? !c.isDefault : false })),
);
},
[],
);
const toggleDefault = useCallback((id: string) => {
setCategories((prev) =>
prev.map((c) => ({ ...c, isDefault: c.id === id ? !c.isDefault : false })),
);
}, []);

useEffect(() => {
if (!defaultMailCategories.length) return;
Expand All @@ -256,10 +245,12 @@ export default function CategoriesSettingsPage() {
setCategories(merged.sort((a, b) => a.order - b.order));
}, [data, defaultMailCategories]);

const handleFieldChange = (id: string, field: keyof CategorySetting, value: string | number | boolean) => {
setCategories((prev) =>
prev.map((cat) => (cat.id === id ? { ...cat, [field]: value } : cat)),
);
const handleFieldChange = (
id: string,
field: keyof CategorySetting,
value: string | number | boolean,
) => {
setCategories((prev) => prev.map((cat) => (cat.id === id ? { ...cat, [field]: value } : cat)));
};

const handleDragEnd = (event: DragEndEvent) => {
Expand All @@ -272,9 +263,9 @@ export default function CategoriesSettingsPage() {
setCategories((prev) => {
const oldIndex = prev.findIndex((cat) => cat.id === active.id);
const newIndex = prev.findIndex((cat) => cat.id === over.id);

const reorderedCategories = arrayMove(prev, oldIndex, newIndex);

return reorderedCategories.map((cat, index) => ({
...cat,
order: index,
Expand Down Expand Up @@ -314,7 +305,7 @@ export default function CategoriesSettingsPage() {
}

return (
<div className="grid gap-6 max-w-[900px] mx-auto">
<div className="mx-auto grid max-w-[900px] gap-6">
<SettingsCard
title="Mail Categories"
description="Customise how Zero shows the category tabs in your inbox. Drag and drop to reorder."
Expand Down Expand Up @@ -342,9 +333,7 @@ export default function CategoriesSettingsPage() {
cat={cat}
isActiveAi={activeAiCat === cat.id}
promptValue={promptValues[cat.id] ?? ''}
setPromptValue={(val) =>
setPromptValues((prev) => ({ ...prev, [cat.id]: val }))
}
setPromptValue={(val) => setPromptValues((prev) => ({ ...prev, [cat.id]: val }))}
setActiveAiCat={setActiveAiCat}
isGeneratingQuery={isGeneratingQuery}
generateSearchQuery={generateSearchQuery}
Expand All @@ -358,4 +347,4 @@ export default function CategoriesSettingsPage() {
</SettingsCard>
</div>
);
}
}