Skip to content
Open
Show file tree
Hide file tree
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
254 changes: 254 additions & 0 deletions components/cmdk.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
"use client";

import * as React from "react";
import { Check, CornerDownLeft, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Kbd } from "./ui/kbd";
import { Button } from "./ui/button";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { authClient } from "@/lib/auth-client";
import { Badge } from "./ui/badge";
import { isThemeNew } from "@/utils/search/is-theme-new";
import { ThemeColors } from "./search/theme-colors";
import { NAVIGATION_ITEMS } from "@/utils/search/constants/navigation";
import { filterPresets } from "@/utils/search/filter-presets";
import { sortThemes } from "@/utils/search/sort-themes";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";

export function CmdK() {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const router = useRouter();

const themeState = useEditorStore((store) => store.themeState);
const applyThemePreset = useEditorStore((store) => store.applyThemePreset);
const currentPreset = themeState.preset;
const mode = themeState.currentMode;

const presets = useThemePresetStore((store) => store.getAllPresets());
const loadSavedPresets = useThemePresetStore((store) => store.loadSavedPresets);
const unloadSavedPresets = useThemePresetStore((store) => store.unloadSavedPresets);

const { data: session } = authClient.useSession();

useEffect(() => {
if (session?.user) {
loadSavedPresets();
} else {
unloadSavedPresets();
}
}, [loadSavedPresets, unloadSavedPresets, session?.user]);

const isSavedTheme = useCallback(
(presetId: string) => {
return presets[presetId]?.source === "SAVED";
},
[presets]
);

const presetNames = useMemo(() => Array.from(new Set(["default", ...Object.keys(presets)])), [presets]);

const filteredPresets = useMemo(() => {
const filteredList = filterPresets(presetNames, presets, search);

// Separate saved and default themes
const savedThemesList = filteredList.filter((name) => name !== "default" && isSavedTheme(name));
const defaultThemesList = filteredList.filter((name) => !savedThemesList.includes(name));

return [...sortThemes(savedThemesList, presets), ...sortThemes(defaultThemesList, presets)];
}, [presetNames, search, presets, isSavedTheme]);
Comment on lines +62 to +70
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Harden filterPresets against missing labels

Because filterPresets ultimately does presets[name]?.label?.toLowerCase().includes(...), any preset without a label will cause a runtime error when searching. Given ThemePreset.label is optional, this is a plausible edge case.

You can guard against missing labels inside filterPresets (file: lib/search/filter-presets.ts) by short‑circuiting:

return presetNames.filter((name) => {
  if (name === "default") {
    return "default".includes(searchLower);
  }
  const label = presets[name]?.label;
  return label ? label.toLowerCase().includes(searchLower) : false;
});

Since CmdK relies on this helper for every keystroke, a defensive check here is worthwhile.



---

🏁 Script executed:

```shell
#!/bin/bash
# Find and inspect filterPresets implementation
echo "=== Searching for filterPresets implementation ==="
fd "filter-presets" --type f

Length of output: 79


🏁 Script executed:

#!/bin/bash
# Find ThemePreset type definition
echo "=== Searching for ThemePreset type definition ==="
rg -n "type ThemePreset|interface ThemePreset" --type ts --type tsx

Length of output: 77


🏁 Script executed:

#!/bin/bash
# Show current filterPresets implementation
echo "=== Current filterPresets content ==="
cat lib/search/filter-presets.ts 2>/dev/null || echo "File not found at expected path"

Length of output: 599


🏁 Script executed:

#!/bin/bash
# Search for ThemePreset definition in all TypeScript files
echo "=== Searching for ThemePreset definition ==="
rg -n "ThemePreset\s*=" --type ts --type tsx -A 5

Length of output: 72



---

🏁 Script executed:

```shell
#!/bin/bash
# Search for ThemePreset definition - corrected file type
echo "=== Searching for ThemePreset definition ==="
rg -n "ThemePreset" --type ts -A 8 | head -40

Length of output: 1974


🏁 Script executed:

#!/bin/bash
# Find the types/theme file
echo "=== Locating types/theme file ==="
fd "theme" --path "*/types/*" --type f

Length of output: 249


🏁 Script executed:

#!/bin/bash
# Directly inspect the theme types file
echo "=== Contents of types/theme.ts ==="
cat types/theme.ts 2>/dev/null || cat src/types/theme.ts 2>/dev/null || echo "Searching for theme types..."

Length of output: 4532


🏁 Script executed:

#!/bin/bash
# Fallback: search for ThemePreset export
echo "=== Finding ThemePreset export ==="
rg -n "export.*ThemePreset" --type ts -B 2 -A 10

Length of output: 8723


Guard against missing labels in filterPresets

The current implementation has a runtime error: when presets[name]?.label is undefined (since ThemePreset.label is optional per types/theme.ts:104), calling .toLowerCase() returns undefined, then .includes() fails with TypeError.

Fix in lib/search/filter-presets.ts:11 by short-circuiting when label is missing:

return label ? label.toLowerCase().includes(search.toLowerCase()) : false;
🤖 Prompt for AI Agents
In components/cmdk.tsx around lines 61-69, the filteredPresets logic triggers a
runtime error when a preset's optional label is missing; update the filter
function in lib/search/filter-presets.ts (around line 11) to guard against
undefined labels by returning false if label is falsy, otherwise call
toLowerCase() on the label and check includes() against the search lowercased
string so the filter short-circuits instead of throwing.


const filteredSavedThemes = useMemo(() => {
return filteredPresets.filter((name) => name !== "default" && isSavedTheme(name));
}, [filteredPresets, isSavedTheme]);

const filteredDefaultThemes = useMemo(() => {
return filteredPresets.filter((name) => name === "default" || !isSavedTheme(name));
}, [filteredPresets, isSavedTheme]);

const filteredNavigation = useMemo(() => {
if (search.trim() === "") {
return NAVIGATION_ITEMS;
}
const searchLower = search.toLowerCase();
return NAVIGATION_ITEMS.filter((item) => {
const matchesLabel = item.label.toLowerCase().includes(searchLower);
const matchesKeywords = item.keywords?.some((keyword) =>
keyword.toLowerCase().includes(searchLower)
);
return matchesLabel || matchesKeywords;
});
}, [search]);

useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};

document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);

const onThemeSelect = (presetName: string) => {
applyThemePreset(presetName);
setOpen(false);
setSearch("");
};

const onNavigationSelect = (href: string) => {
router.push(href);
setOpen(false);
setSearch("");
};

return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"outline"}
className="flex h-8 items-center justify-between gap-6"
onClick={() => setOpen(true)}
aria-label="Open search"
>
<Search className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Search (⌘K)</p>
</TooltipContent>
</Tooltip>
<CommandDialog open={open} onOpenChange={setOpen} title="Search">
<div className="bg-card m-1 rounded-md">
<div className="mb-2 flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="Search themes, pages, and more..."
className="border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<CommandList className="mb-2 h-80 p-0 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<CommandEmpty className="flex items-center justify-center py-32">
<p>No results found.</p>
</CommandEmpty>

{/* Navigation Group */}
{filteredNavigation.length > 0 && (
<>
<CommandGroup heading="Navigation" className="">
{filteredNavigation.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={item.id}
value={item.id}
onSelect={() => onNavigationSelect(item.href)}
className="flex items-center gap-2"
>
<Icon className="h-3 w-3 shrink-0" />
<span className="text-sm">{item.label}</span>
</CommandItem>
);
})}
</CommandGroup>
{(filteredSavedThemes.length > 0 || filteredDefaultThemes.length > 0) && (
<CommandSeparator />
)}
</>
)}

{/* Saved Themes Group */}
{filteredSavedThemes.length > 0 && (
<>
<CommandGroup heading="Saved Themes">
{filteredSavedThemes.map((presetName, index) => (
<CommandItem
key={`${presetName}-${index}`}
value={`${presetName}-${index}`}
onSelect={() => onThemeSelect(presetName)}
className="flex items-center gap-2"
>
<ThemeColors presetName={presetName} mode={mode} />

<div className="flex flex-1 items-center gap-2">
<span className="line-clamp-1 text-sm font-medium capitalize">
{presets[presetName]?.label || presetName}
</span>
{presets[presetName] && isThemeNew(presets[presetName]) && (
<Badge variant="secondary" className="rounded-full text-xs">
New
</Badge>
)}
</div>
{presetName === currentPreset && (
<Check className="h-4 w-4 shrink-0 opacity-70" />
)}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}

{/* Built-in Themes Group */}
{filteredDefaultThemes.length > 0 && (
<CommandGroup heading="Built-in Themes">
{filteredDefaultThemes.map((presetName, index) => (
<CommandItem
key={`${presetName}-${index}`}
value={`${presetName}-${index}`}
onSelect={() => onThemeSelect(presetName)}
className="flex items-center gap-2"
>
<ThemeColors presetName={presetName} mode={mode} />
<div className="flex flex-1 items-center gap-2">
<span className="line-clamp-1 text-sm font-medium capitalize">
{presets[presetName]?.label || presetName}
</span>
{presets[presetName] && isThemeNew(presets[presetName]) && (
<Badge variant="secondary" className="rounded-full text-xs">
New
</Badge>
)}
</div>
{presetName === currentPreset && (
<Check className="h-4 w-4 shrink-0 opacity-70" />
)}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</div>
<CommandFooter />
</CommandDialog>
</>
);
}

const CommandFooter = () => {
return (
<div className="text-muted-foreground flex items-center gap-2 px-6 py-2 text-xs">
<Kbd className="rounded-[4px] px-1">
<CornerDownLeft />
</Kbd>
<p className="font-medium">Go to page</p>
</div>
);
};
80 changes: 11 additions & 69 deletions components/editor/theme-preset-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { ThemePreset } from "@/types/theme";
import { getPresetThemeStyles } from "@/utils/theme-preset-helper";
import Link from "next/link";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ThemeToggle } from "../theme-toggle";
import { TooltipWrapper } from "../tooltip-wrapper";
import { ThemeColors } from "../search/theme-colors";
import { filterPresets } from "@/utils/search/filter-presets";
import { sortThemes } from "@/utils/search/sort-themes";
import { isThemeNew } from "@/utils/search/is-theme-new";
import {
ArrowLeft,
ArrowRight,
Expand All @@ -22,48 +28,11 @@ import {
Settings,
Shuffle,
} from "lucide-react";
import Link from "next/link";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ThemeToggle } from "../theme-toggle";
import { TooltipWrapper } from "../tooltip-wrapper";

interface ThemePresetSelectProps extends React.ComponentProps<typeof Button> {
withCycleThemes?: boolean;
}

interface ColorBoxProps {
color: string;
}

const ColorBox: React.FC<ColorBoxProps> = ({ color }) => (
<div className="border-muted h-3 w-3 rounded-sm border" style={{ backgroundColor: color }} />
);

interface ThemeColorsProps {
presetName: string;
mode: "light" | "dark";
}

const ThemeColors: React.FC<ThemeColorsProps> = ({ presetName, mode }) => {
const styles = getPresetThemeStyles(presetName)[mode];
return (
<div className="flex gap-0.5">
<ColorBox color={styles.primary} />
<ColorBox color={styles.accent} />
<ColorBox color={styles.secondary} />
<ColorBox color={styles.border} />
</div>
);
};

const isThemeNew = (preset: ThemePreset) => {
if (!preset.createdAt) return false;
const createdAt = new Date(preset.createdAt);
const timePeriod = new Date();
timePeriod.setDate(timePeriod.getDate() - 5);
return createdAt > timePeriod;
};

const ThemeControls = () => {
const applyThemePreset = useEditorStore((store) => store.applyThemePreset);
const presets = useThemePresetStore((store) => store.getAllPresets());
Expand Down Expand Up @@ -211,35 +180,13 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
const currentPresetName = presetNames?.find((name) => name === currentPreset);

const filteredPresets = useMemo(() => {
const filteredList =
search.trim() === ""
? presetNames
: presetNames.filter((name) => {
if (name === "default") {
return "default".toLowerCase().includes(search.toLowerCase());
}
return presets[name]?.label?.toLowerCase().includes(search.toLowerCase());
});
const filteredList = filterPresets(presetNames, presets, search);

// Separate saved and default themes
const savedThemesList = filteredList.filter((name) => name !== "default" && isSavedTheme(name));
const defaultThemesList = filteredList.filter((name) => !savedThemesList.includes(name));

// Sort each list, with "default" at the top for default themes
const sortThemes = (list: string[]) => {
const defaultTheme = list.filter((name) => name === "default");
const otherThemes = list
.filter((name) => name !== "default")
.sort((a, b) => {
const labelA = presets[a]?.label || a;
const labelB = presets[b]?.label || b;
return labelA.localeCompare(labelB);
});
return [...defaultTheme, ...otherThemes];
};

// Combine saved themes first, then default themes
return [...sortThemes(savedThemesList), ...sortThemes(defaultThemesList)];
return [...sortThemes(savedThemesList, presets), ...sortThemes(defaultThemesList, presets)];
}, [presetNames, search, presets, isSavedTheme]);

const filteredSavedThemes = useMemo(() => {
Expand All @@ -260,12 +207,7 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
{...props}
>
<div className="flex w-full items-center gap-3 overflow-hidden">
<div className="flex gap-0.5">
<ColorBox color={themeState.styles[mode].primary} />
<ColorBox color={themeState.styles[mode].accent} />
<ColorBox color={themeState.styles[mode].secondary} />
<ColorBox color={themeState.styles[mode].border} />
</div>
<ThemeColors presetName={currentPresetName || "default"} mode={mode} />
{currentPresetName !== "default" &&
currentPresetName &&
isSavedTheme(currentPresetName) &&
Expand Down
2 changes: 1 addition & 1 deletion components/get-pro-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function GetProCTA({ className, ...props }: GetProCTAProps) {
>
<Link href="/pricing">
<Gem />
Get Pro
<span className="hidden lg:block">Get Pro</span>
</Link>
</Button>
);
Expand Down
Loading