Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
40 changes: 40 additions & 0 deletions apps/web/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# CLAUDE.md - Development Guidelines

## Build & Test Commands

- Development: `pnpm dev`
- Build: `pnpm build`
- Lint: `pnpm lint`
- Run all tests: `pnpm test`
- Run AI tests: `pnpm test-ai`
- Run single test: `pnpm test __tests__/test-file.test.ts`
- Run specific AI test: `pnpm test-ai ai-categorize-senders`

## Code Style

- Use TypeScript with strict null checks
- Path aliases: Use `@/` for imports from project root
- NextJS app router structure with (app) directory
- Follow tailwindcss patterns with prettier-plugin-tailwindcss
- Prefer functional components with hooks
- Use proper error handling with try/catch blocks
- Format code with Prettier
- Consult .cursor/rules for environment variable management

## Component Guidelines

- Use shadcn/ui components when available
- Ensure responsive design with mobile-first approach
- Follow consistent naming conventions (PascalCase for components)
- Centralize types in dedicated type files when shared
- Use LoadingContent component for async data:
```tsx
<LoadingContent loading={isLoading} error={error}>
{data && <YourComponent data={data} />}
</LoadingContent>
```

## Environment Variables

- Add to `.env.example`, `env.ts`, and `turbo.json`
- Client-side vars: Prefix with `NEXT_PUBLIC_`
13 changes: 9 additions & 4 deletions apps/web/app/(app)/mail/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ import { PermissionsCheck } from "@/app/(app)/PermissionsCheck";
export default function Mail({
searchParams,
}: {
searchParams: { type?: string };
searchParams: { type?: string; labelId?: string };
}) {
const query: ThreadsQuery = searchParams.type
? { type: searchParams.type }
: {};
const query: ThreadsQuery = {};

// Handle different query params
if (searchParams.type === "label" && searchParams.labelId) {
query.labelId = searchParams.labelId;
} else if (searchParams.type) {
query.type = searchParams.type;
}

const getKey = (
pageIndex: number,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/google/threads/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function getThreads(query: ThreadsQuery) {
await getThreadsWithNextPageToken({
gmail,
q: getQuery(),
labelIds: getLabelIds(query.type),
labelIds: query.labelId ? [query.labelId] : getLabelIds(query.type),
maxResults: query.limit || 50,
pageToken: query.nextPageToken || undefined,
});
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/api/google/threads/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ export const GET = withError(async (request: Request) => {
const type = searchParams.get("type");
const nextPageToken = searchParams.get("nextPageToken");
const q = searchParams.get("q");
const labelId = searchParams.get("labelId");
const query = threadsQuery.parse({
limit,
fromEmail,
type,
nextPageToken,
q,
labelId,
});

const threads = await getThreads(query);
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/google/threads/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export const threadsQuery = z.object({
type: z.string().nullish(),
q: z.string().nullish(),
nextPageToken: z.string().nullish(),
labelId: z.string().nullish(),
});
export type ThreadsQuery = z.infer<typeof threadsQuery>;
73 changes: 72 additions & 1 deletion apps/web/components/SideNav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo } from "react";
import { useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import {
Expand All @@ -9,6 +9,8 @@ import {
ArrowLeftIcon,
BarChartBigIcon,
BookIcon,
ChevronDownIcon,
ChevronRightIcon,
CogIcon,
CrownIcon,
FileIcon,
Expand Down Expand Up @@ -45,6 +47,8 @@ import {
} from "@/components/ui/sidebar";
import { SideNavMenu } from "@/components/SideNavMenu";
import { CommandShortcut } from "@/components/ui/command";
import { useLabels } from "@/hooks/useLabels";
import { LoadingContent } from "@/components/LoadingContent";

type NavItem = {
name: string;
Expand Down Expand Up @@ -259,6 +263,38 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {

function MailNav({ path }: { path: string }) {
const { onOpen } = useComposeModal();
const [showHiddenLabels, setShowHiddenLabels] = useState(false);
const { userLabels, hiddenUserLabels, isLoading } = useLabels({
includeHidden: showHiddenLabels,
});

// Transform user labels into NavItems
const labelNavItems = useMemo(() => {
const searchParams = new URLSearchParams(path.split("?")[1] || "");
const currentLabelId = searchParams.get("labelId");

return userLabels.map((label) => ({
name: label.name,
icon: TagIcon,
href: `?type=label&labelId=${encodeURIComponent(label.id)}`,
// Add active state for the current label
active: currentLabelId === label.id,
}));
}, [userLabels, path]);

// Transform hidden labels into NavItems
const hiddenLabelNavItems = useMemo(() => {
const searchParams = new URLSearchParams(path.split("?")[1] || "");
const currentLabelId = searchParams.get("labelId");

return hiddenUserLabels.map((label) => ({
name: label.name,
icon: TagIcon,
href: `?type=label&labelId=${encodeURIComponent(label.id)}`,
// Add active state for the current label
active: currentLabelId === label.id,
}));
}, [hiddenUserLabels, path]);

return (
<>
Expand All @@ -284,6 +320,41 @@ function MailNav({ path }: { path: string }) {
<SidebarGroupLabel>Categories</SidebarGroupLabel>
<SideNavMenu items={bottomMailLinks} activeHref={path} />
</SidebarGroup>

<SidebarGroup>
<SidebarGroupLabel>Labels</SidebarGroupLabel>
<LoadingContent loading={isLoading}>
{userLabels.length > 0 ? (
<SideNavMenu items={labelNavItems} activeHref={path} />
) : (
<div className="px-3 py-2 text-xs text-muted-foreground">
No labels
</div>
)}

Comment on lines +326 to +332

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add error handling for the loading state.

The LoadingContent component should handle error states from the useLabels hook.

-<LoadingContent loading={isLoading}>
+<LoadingContent loading={isLoading} error={error}>
   {userLabels.length > 0 ? (
     <SideNavMenu items={labelNavItems} activeHref={path} />
   ) : (
     <div className="px-3 py-2 text-xs text-muted-foreground">
-      No labels
+      {error ? 'Failed to load labels' : 'No labels'}
     </div>
   )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<LoadingContent loading={isLoading}>
{userLabels.length > 0 ? (
<SideNavMenu items={labelNavItems} activeHref={path} />
) : (
<div className="px-3 py-2 text-xs text-muted-foreground">
No labels
</div>
)}
<LoadingContent loading={isLoading} error={error}>
{userLabels.length > 0 ? (
<SideNavMenu items={labelNavItems} activeHref={path} />
) : (
<div className="px-3 py-2 text-xs text-muted-foreground">
{error ? 'Failed to load labels' : 'No labels'}
</div>
)}

{/* Hidden labels toggle */}
{hiddenUserLabels.length > 0 && (
<>
<button
type="button"
onClick={() => setShowHiddenLabels(!showHiddenLabels)}
className="flex w-full items-center px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{showHiddenLabels ? (
<ChevronDownIcon className="mr-1 size-4" />
) : (
<ChevronRightIcon className="mr-1 size-4" />
)}
<span>More</span>
</button>
Comment on lines +336 to +347

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add accessibility attributes to the toggle button.

The toggle button for hidden labels should have proper accessibility attributes for better screen reader support.

 <button
   type="button"
   onClick={() => setShowHiddenLabels(!showHiddenLabels)}
+  aria-expanded={showHiddenLabels}
+  aria-controls="hidden-labels-menu"
   className="flex w-full items-center px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
 >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={() => setShowHiddenLabels(!showHiddenLabels)}
className="flex w-full items-center px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{showHiddenLabels ? (
<ChevronDownIcon className="mr-1 size-4" />
) : (
<ChevronRightIcon className="mr-1 size-4" />
)}
<span>More</span>
</button>
<button
type="button"
onClick={() => setShowHiddenLabels(!showHiddenLabels)}
aria-expanded={showHiddenLabels}
aria-controls="hidden-labels-menu"
className="flex w-full items-center px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{showHiddenLabels ? (
<ChevronDownIcon className="mr-1 size-4" />
) : (
<ChevronRightIcon className="mr-1 size-4" />
)}
<span>More</span>
</button>


{showHiddenLabels && (
<SideNavMenu items={hiddenLabelNavItems} activeHref={path} />
)}
</>
)}
</LoadingContent>
</SidebarGroup>
</>
);
}
3 changes: 2 additions & 1 deletion apps/web/components/SideNavMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type NavItem = {
target?: "_blank";
count?: number;
hideInMail?: boolean;
active?: boolean;
};

export function SideNavMenu({
Expand All @@ -30,7 +31,7 @@ export function SideNavMenu({
<SidebarMenuItem key={item.name} className="font-semibold">
<SidebarMenuButton
asChild
isActive={activeHref === item.href}
isActive={item.active || activeHref === item.href}
className="h-9"
tooltip={item.name}
>
Expand Down
62 changes: 47 additions & 15 deletions apps/web/hooks/useLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,60 @@ import useSWR from "swr";
import type { LabelsResponse } from "@/app/api/google/labels/route";
import type { gmail_v1 } from "@googleapis/gmail";

export type UserLabel = { id: string; name: string; type: "user" };
export type UserLabel = {
id: string;
name: string;
type: "user";
labelListVisibility?: string;
messageListVisibility?: string;
};

export function useLabels() {
export function useLabels(options = { includeHidden: false }) {
const { data, isLoading, error, mutate } =
useSWR<LabelsResponse>("/api/google/labels");

const userLabels = useMemo(
() =>
data?.labels?.filter(isUserLabel).sort((a, b) => {
const aName = a.name || "";
const bName = b.name || "";

// order words that start with [ at the end
if (aName.startsWith("[") && !bName.startsWith("[")) return 1;
if (!aName.startsWith("[") && bName.startsWith("[")) return -1;

return aName.localeCompare(bName);
}) || [],
// Get all user labels
const allUserLabels = useMemo(
() => data?.labels?.filter(isUserLabel) || [],
[data?.labels],
);

return { userLabels, data, isLoading, error, mutate };
// Split into visible and hidden labels
const { userLabels, hiddenUserLabels } = useMemo(() => {
// Always get visible labels
const visible = allUserLabels
.filter((label) => label.labelListVisibility !== "labelHide")
.sort(sortLabels);

// Only process hidden labels if needed
const hidden = options.includeHidden
? allUserLabels
.filter((label) => label.labelListVisibility === "labelHide")
.sort(sortLabels)
: [];

return { userLabels: visible, hiddenUserLabels: hidden };
}, [allUserLabels, options.includeHidden]);

return {
userLabels,
hiddenUserLabels,
data,
isLoading,
error,
mutate,
};
}

function sortLabels(a: UserLabel, b: UserLabel) {
const aName = a.name || "";
const bName = b.name || "";

// Order words that start with [ at the end
if (aName.startsWith("[") && !bName.startsWith("[")) return 1;
if (!aName.startsWith("[") && bName.startsWith("[")) return -1;

return aName.localeCompare(bName);
}

function isUserLabel(label: gmail_v1.Schema$Label): label is UserLabel {
Expand Down