diff --git a/components/cmdk.tsx b/components/cmdk.tsx new file mode 100644 index 00000000..b92ca33e --- /dev/null +++ b/components/cmdk.tsx @@ -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]); + + 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 ( + <> + + + + + +

Search (⌘K)

+
+
+ +
+
+ + setSearch(e.target.value)} + /> +
+ + +

No results found.

+
+ + {/* Navigation Group */} + {filteredNavigation.length > 0 && ( + <> + + {filteredNavigation.map((item) => { + const Icon = item.icon; + return ( + onNavigationSelect(item.href)} + className="flex items-center gap-2" + > + + {item.label} + + ); + })} + + {(filteredSavedThemes.length > 0 || filteredDefaultThemes.length > 0) && ( + + )} + + )} + + {/* Saved Themes Group */} + {filteredSavedThemes.length > 0 && ( + <> + + {filteredSavedThemes.map((presetName, index) => ( + onThemeSelect(presetName)} + className="flex items-center gap-2" + > + + +
+ + {presets[presetName]?.label || presetName} + + {presets[presetName] && isThemeNew(presets[presetName]) && ( + + New + + )} +
+ {presetName === currentPreset && ( + + )} +
+ ))} +
+ + + )} + + {/* Built-in Themes Group */} + {filteredDefaultThemes.length > 0 && ( + + {filteredDefaultThemes.map((presetName, index) => ( + onThemeSelect(presetName)} + className="flex items-center gap-2" + > + +
+ + {presets[presetName]?.label || presetName} + + {presets[presetName] && isThemeNew(presets[presetName]) && ( + + New + + )} +
+ {presetName === currentPreset && ( + + )} +
+ ))} +
+ )} +
+
+ +
+ + ); +} + +const CommandFooter = () => { + return ( +
+ + + +

Go to page

+
+ ); +}; diff --git a/components/editor/theme-preset-select.tsx b/components/editor/theme-preset-select.tsx index 375d8a6c..e3ddbe0c 100644 --- a/components/editor/theme-preset-select.tsx +++ b/components/editor/theme-preset-select.tsx @@ -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, @@ -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 { withCycleThemes?: boolean; } -interface ColorBoxProps { - color: string; -} - -const ColorBox: React.FC = ({ color }) => ( -
-); - -interface ThemeColorsProps { - presetName: string; - mode: "light" | "dark"; -} - -const ThemeColors: React.FC = ({ presetName, mode }) => { - const styles = getPresetThemeStyles(presetName)[mode]; - return ( -
- - - - -
- ); -}; - -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()); @@ -211,35 +180,13 @@ const ThemePresetSelect: React.FC = ({ 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(() => { @@ -260,12 +207,7 @@ const ThemePresetSelect: React.FC = ({ {...props} >
-
- - - - -
+ {currentPresetName !== "default" && currentPresetName && isSavedTheme(currentPresetName) && diff --git a/components/get-pro-cta.tsx b/components/get-pro-cta.tsx index aa0eda8b..761eb412 100644 --- a/components/get-pro-cta.tsx +++ b/components/get-pro-cta.tsx @@ -28,7 +28,7 @@ export function GetProCTA({ className, ...props }: GetProCTAProps) { > - Get Pro + Get Pro ); diff --git a/components/header.tsx b/components/header.tsx index 0435f99d..7d261194 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -15,6 +15,7 @@ import { formatCompactNumber } from "@/utils/format"; import Link from "next/link"; import { useState } from "react"; import { GetProCTA } from "./get-pro-cta"; +import { CmdK } from "./cmdk"; export function Header() { const { stargazersCount } = useGithubStars("jnsahaj", "tweakcn"); @@ -26,21 +27,23 @@ export function Header() {
- tweakcn + tweakcn
+ + {stargazersCount > 0 && formatCompactNumber(stargazersCount)} - -
+ +
@@ -50,14 +53,15 @@ export function Header() {
- +
diff --git a/components/search/color-box.tsx b/components/search/color-box.tsx new file mode 100644 index 00000000..8f56d74e --- /dev/null +++ b/components/search/color-box.tsx @@ -0,0 +1,7 @@ +interface ColorBoxProps { + color: string; +} + +export const ColorBox: React.FC = ({ color }) => ( +
+); diff --git a/components/search/theme-colors.tsx b/components/search/theme-colors.tsx new file mode 100644 index 00000000..aef8d43d --- /dev/null +++ b/components/search/theme-colors.tsx @@ -0,0 +1,20 @@ +import { getPresetThemeStyles } from "@/utils/theme-preset-helper"; +import { ColorBox } from "./color-box"; +import { useMemo } from "react"; + +interface ThemeColorsProps { + presetName: string; + mode: "light" | "dark"; +} + +export const ThemeColors: React.FC = ({ presetName, mode }) => { + const styles = useMemo(() => getPresetThemeStyles(presetName)[mode], [presetName, mode]); + return ( +
+ + + + +
+ ); +}; diff --git a/components/ui/command.tsx b/components/ui/command.tsx index 035363fe..738ec945 100644 --- a/components/ui/command.tsx +++ b/components/ui/command.tsx @@ -21,12 +21,14 @@ const Command = React.forwardRef< )); Command.displayName = CommandPrimitive.displayName; -interface CommandDialogProps extends DialogProps {} +interface CommandDialogProps extends DialogProps { + title?: string; +} -const CommandDialog = ({ children, ...props }: CommandDialogProps) => { +const CommandDialog = ({ children, title, ...props }: CommandDialogProps) => { return ( - + {children} diff --git a/components/ui/kbd.tsx b/components/ui/kbd.tsx new file mode 100644 index 00000000..add40e8e --- /dev/null +++ b/components/ui/kbd.tsx @@ -0,0 +1,28 @@ +import { cn } from "@/lib/utils" + +function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { + return ( + + ) +} + +function KbdGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Kbd, KbdGroup } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e009c75..8e6e2adb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2260,19 +2260,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.0.3': - resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-primitive@2.1.0': resolution: {integrity: sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==} peerDependencies: @@ -8842,15 +8829,6 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.2 - '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-primitive@2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) @@ -10315,7 +10293,7 @@ snapshots: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-dialog': 1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: diff --git a/utils/search/constants/navigation.ts b/utils/search/constants/navigation.ts new file mode 100644 index 00000000..ab07baf7 --- /dev/null +++ b/utils/search/constants/navigation.ts @@ -0,0 +1,55 @@ +import type { LucideIcon } from "lucide-react"; +import { CreditCard, Sparkles, Palette, Figma, Settings, ChartNoAxesCombined } from "lucide-react"; + +interface NavigationItem { + id: string; + label: string; + href: string; + icon: LucideIcon; + keywords?: string[]; +} + +export const NAVIGATION_ITEMS: NavigationItem[] = [ + { + id: "editor", + label: "Editor", + href: "/editor/theme", + icon: Palette, + keywords: ["theme", "edit", "create"], + }, + { + id: "pricing", + label: "Pricing", + href: "/pricing", + icon: CreditCard, + keywords: ["pro", "subscribe", "upgrade"], + }, + { + id: "ai", + label: "AI Generator", + href: "/ai", + icon: Sparkles, + keywords: ["generate", "ai", "create"], + }, + { + id: "figma", + label: "Figma", + href: "/figma", + icon: Figma, + keywords: ["figma", "design", "import"], + }, + { + id: "settings-themes", + label: "Themes Settings", + href: "/settings/themes", + icon: Settings, + keywords: ["settings", "themes", "manage"], + }, + { + id: "settings-usage", + label: "AI Usage", + href: "/settings/usage", + icon: ChartNoAxesCombined, + keywords: ["settings", "usage", "stats"], + }, +]; diff --git a/utils/search/filter-presets.ts b/utils/search/filter-presets.ts new file mode 100644 index 00000000..e0b75ed9 --- /dev/null +++ b/utils/search/filter-presets.ts @@ -0,0 +1,17 @@ +import { ThemePreset } from "@/types/theme"; + +export const filterPresets = (presetNames: string[], presets: Record, search: string) => { + const searchLower = search.toLowerCase(); + const filteredList = + search.trim() === "" + ? presetNames + : presetNames.filter((name) => { + if (name === "default") { + return "default".includes(searchLower); + } + const label = presets[name]?.label; + return label ? label.toLowerCase().includes(searchLower) : false; + }); + + return filteredList; +}; diff --git a/utils/search/is-theme-new.ts b/utils/search/is-theme-new.ts new file mode 100644 index 00000000..c4688299 --- /dev/null +++ b/utils/search/is-theme-new.ts @@ -0,0 +1,9 @@ +import { ThemePreset } from "@/types/theme"; + +export 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; +}; \ No newline at end of file diff --git a/utils/search/sort-themes.ts b/utils/search/sort-themes.ts new file mode 100644 index 00000000..3ad4c3ba --- /dev/null +++ b/utils/search/sort-themes.ts @@ -0,0 +1,13 @@ +import { ThemePreset } from "@/types/theme"; + +export const sortThemes = (list: string[], presets: Record) => { + 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]; +}; diff --git a/utils/theme-preset-helper.ts b/utils/theme-preset-helper.ts index e59fc551..8e102e26 100644 --- a/utils/theme-preset-helper.ts +++ b/utils/theme-preset-helper.ts @@ -33,8 +33,7 @@ function mergePresetWithDefaults(presetStyles: { }, dark: { ...defaultTheme.dark, - ...(presetStyles.light || {}), - ...(presetStyles.dark || {}), + ...(preset.styles.dark || {}), }, }; }