From 05a45739db1ffd9948c2ac02ac97df6f2de39b83 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 24 Feb 2025 21:23:57 +0200 Subject: [PATCH 1/3] Add labels in mail sidebar. thanks claude code --- apps/web/CLAUDE.md | 40 ++++++++++ apps/web/app/(app)/mail/page.tsx | 13 +++- apps/web/app/api/google/threads/controller.ts | 2 +- apps/web/app/api/google/threads/route.ts | 2 + apps/web/app/api/google/threads/validation.ts | 1 + apps/web/components/SideNav.tsx | 73 ++++++++++++++++++- apps/web/components/SideNavMenu.tsx | 3 +- apps/web/hooks/useLabels.ts | 62 ++++++++++++---- 8 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 apps/web/CLAUDE.md diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 0000000000..8fa458fd57 --- /dev/null +++ b/apps/web/CLAUDE.md @@ -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 + + {data && } + + ``` + +## Environment Variables + +- Add to `.env.example`, `env.ts`, and `turbo.json` +- Client-side vars: Prefix with `NEXT_PUBLIC_` diff --git a/apps/web/app/(app)/mail/page.tsx b/apps/web/app/(app)/mail/page.tsx index 507240c62f..6f8e43f403 100644 --- a/apps/web/app/(app)/mail/page.tsx +++ b/apps/web/app/(app)/mail/page.tsx @@ -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, diff --git a/apps/web/app/api/google/threads/controller.ts b/apps/web/app/api/google/threads/controller.ts index f93d158a92..ff8bc357bb 100644 --- a/apps/web/app/api/google/threads/controller.ts +++ b/apps/web/app/api/google/threads/controller.ts @@ -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, }); diff --git a/apps/web/app/api/google/threads/route.ts b/apps/web/app/api/google/threads/route.ts index 3bef54b90c..237bd0b5b9 100644 --- a/apps/web/app/api/google/threads/route.ts +++ b/apps/web/app/api/google/threads/route.ts @@ -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); diff --git a/apps/web/app/api/google/threads/validation.ts b/apps/web/app/api/google/threads/validation.ts index d888bbaac0..ac349224a2 100644 --- a/apps/web/app/api/google/threads/validation.ts +++ b/apps/web/app/api/google/threads/validation.ts @@ -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; diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index bd1605ad6d..6d0a547ce6 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -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 { @@ -9,6 +9,8 @@ import { ArrowLeftIcon, BarChartBigIcon, BookIcon, + ChevronDownIcon, + ChevronRightIcon, CogIcon, CrownIcon, FileIcon, @@ -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; @@ -259,6 +263,38 @@ export function AppSidebar({ ...props }: React.ComponentProps) { 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 ( <> @@ -284,6 +320,41 @@ function MailNav({ path }: { path: string }) { Categories + + + Labels + + {userLabels.length > 0 ? ( + + ) : ( +
+ No labels +
+ )} + + {/* Hidden labels toggle */} + {hiddenUserLabels.length > 0 && ( + <> + + + {showHiddenLabels && ( + + )} + + )} +
+
); } diff --git a/apps/web/components/SideNavMenu.tsx b/apps/web/components/SideNavMenu.tsx index c51ea5c2f3..b6edf2953c 100644 --- a/apps/web/components/SideNavMenu.tsx +++ b/apps/web/components/SideNavMenu.tsx @@ -15,6 +15,7 @@ type NavItem = { target?: "_blank"; count?: number; hideInMail?: boolean; + active?: boolean; }; export function SideNavMenu({ @@ -30,7 +31,7 @@ export function SideNavMenu({ diff --git a/apps/web/hooks/useLabels.ts b/apps/web/hooks/useLabels.ts index 14eba2b79e..6cc7908f4a 100644 --- a/apps/web/hooks/useLabels.ts +++ b/apps/web/hooks/useLabels.ts @@ -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("/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 { From 91258c45c4a8186af08640504f093d7399cb261e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 24 Feb 2025 21:28:26 +0200 Subject: [PATCH 2/3] fix show hidden --- apps/web/components/SideNav.tsx | 2 +- apps/web/hooks/useLabels.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index 6d0a547ce6..042c4d4da1 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -265,7 +265,7 @@ function MailNav({ path }: { path: string }) { const { onOpen } = useComposeModal(); const [showHiddenLabels, setShowHiddenLabels] = useState(false); const { userLabels, hiddenUserLabels, isLoading } = useLabels({ - includeHidden: showHiddenLabels, + includeHidden: true, }); // Transform user labels into NavItems diff --git a/apps/web/hooks/useLabels.ts b/apps/web/hooks/useLabels.ts index 6cc7908f4a..f80126586d 100644 --- a/apps/web/hooks/useLabels.ts +++ b/apps/web/hooks/useLabels.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import useSWR from "swr"; import type { LabelsResponse } from "@/app/api/google/labels/route"; import type { gmail_v1 } from "@googleapis/gmail"; +import { labelVisibility } from "@/utils/gmail/constants"; export type UserLabel = { id: string; @@ -11,7 +12,7 @@ export type UserLabel = { messageListVisibility?: string; }; -export function useLabels(options = { includeHidden: false }) { +export function useLabels(options = { includeHidden: true }) { const { data, isLoading, error, mutate } = useSWR("/api/google/labels"); @@ -24,15 +25,11 @@ export function useLabels(options = { includeHidden: false }) { // Split into visible and hidden labels const { userLabels, hiddenUserLabels } = useMemo(() => { // Always get visible labels - const visible = allUserLabels - .filter((label) => label.labelListVisibility !== "labelHide") - .sort(sortLabels); + const visible = allUserLabels.filter(isHiddenLabel).sort(sortLabels); // Only process hidden labels if needed const hidden = options.includeHidden - ? allUserLabels - .filter((label) => label.labelListVisibility === "labelHide") - .sort(sortLabels) + ? allUserLabels.filter(isHiddenLabel).sort(sortLabels) : []; return { userLabels: visible, hiddenUserLabels: hidden }; @@ -62,3 +59,7 @@ function sortLabels(a: UserLabel, b: UserLabel) { function isUserLabel(label: gmail_v1.Schema$Label): label is UserLabel { return label.type === "user"; } + +function isHiddenLabel(label: UserLabel) { + return label.labelListVisibility === labelVisibility.labelHide; +} From 5a9d3e10b4d3cc04e179a7f1db35c33f662769c3 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 24 Feb 2025 21:32:02 +0200 Subject: [PATCH 3/3] split to 2 hooks --- apps/web/components/SideNav.tsx | 18 +++++------ apps/web/hooks/useLabels.ts | 54 +++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index 042c4d4da1..0bf21168b9 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -47,7 +47,7 @@ import { } from "@/components/ui/sidebar"; import { SideNavMenu } from "@/components/SideNavMenu"; import { CommandShortcut } from "@/components/ui/command"; -import { useLabels } from "@/hooks/useLabels"; +import { useSplitLabels } from "@/hooks/useLabels"; import { LoadingContent } from "@/components/LoadingContent"; type NavItem = { @@ -264,37 +264,35 @@ export function AppSidebar({ ...props }: React.ComponentProps) { function MailNav({ path }: { path: string }) { const { onOpen } = useComposeModal(); const [showHiddenLabels, setShowHiddenLabels] = useState(false); - const { userLabels, hiddenUserLabels, isLoading } = useLabels({ - includeHidden: true, - }); + const { visibleLabels, hiddenLabels, isLoading } = useSplitLabels(); // Transform user labels into NavItems const labelNavItems = useMemo(() => { const searchParams = new URLSearchParams(path.split("?")[1] || ""); const currentLabelId = searchParams.get("labelId"); - return userLabels.map((label) => ({ + return visibleLabels.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]); + }, [visibleLabels, 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) => ({ + return hiddenLabels.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]); + }, [hiddenLabels, path]); return ( <> @@ -324,7 +322,7 @@ function MailNav({ path }: { path: string }) { Labels - {userLabels.length > 0 ? ( + {visibleLabels.length > 0 ? ( ) : (
@@ -333,7 +331,7 @@ function MailNav({ path }: { path: string }) { )} {/* Hidden labels toggle */} - {hiddenUserLabels.length > 0 && ( + {hiddenLabels.length > 0 && ( <>