mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-09-01 15:37:45 +08:00
2667bb63a4
Adds a "Popular" section at the top of the font picker dropdown with 10+ curated fonts per category (sans-serif, serif, monospace, display, handwriting) including Geist and Geist Mono. The rest of the fonts continue in alphabetical order under "All Fonts". The popular section hides when searching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
416 lines
18 KiB
TypeScript
416 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
|
import { FilterFontCategory, useFontSearch } from "@/hooks/use-font-search";
|
|
import { cn } from "@/lib/utils";
|
|
import { FontInfo } from "@/types/fonts";
|
|
import { buildFontFamily, getDefaultWeights, waitForFont } from "@/utils/fonts";
|
|
import { loadGoogleFont } from "@/utils/fonts/google-fonts";
|
|
import { Check, ChevronDown, FunnelX, Loader2 } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { TooltipWrapper } from "../tooltip-wrapper";
|
|
|
|
const POPULAR_FONTS: Record<string, FontInfo[]> = {
|
|
"sans-serif": [
|
|
{ family: "Inter", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Roboto", category: "sans-serif", variants: ["100", "300", "400", "500", "700", "900"], variable: false },
|
|
{ family: "Open Sans", category: "sans-serif", variants: ["300", "400", "500", "600", "700", "800"], variable: true },
|
|
{ family: "Poppins", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: false },
|
|
{ family: "Montserrat", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Lato", category: "sans-serif", variants: ["100", "300", "400", "700", "900"], variable: false },
|
|
{ family: "Nunito", category: "sans-serif", variants: ["200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Raleway", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "DM Sans", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Plus Jakarta Sans", category: "sans-serif", variants: ["200", "300", "400", "500", "600", "700", "800"], variable: true },
|
|
{ family: "Geist", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
],
|
|
serif: [
|
|
{ family: "Playfair Display", category: "serif", variants: ["400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Merriweather", category: "serif", variants: ["300", "400", "700", "900"], variable: false },
|
|
{ family: "Lora", category: "serif", variants: ["400", "500", "600", "700"], variable: true },
|
|
{ family: "PT Serif", category: "serif", variants: ["400", "700"], variable: false },
|
|
{ family: "Noto Serif", category: "serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Source Serif 4", category: "serif", variants: ["200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Libre Baskerville", category: "serif", variants: ["400", "700"], variable: false },
|
|
{ family: "EB Garamond", category: "serif", variants: ["400", "500", "600", "700", "800"], variable: true },
|
|
{ family: "Crimson Text", category: "serif", variants: ["400", "600", "700"], variable: false },
|
|
{ family: "Bitter", category: "serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
],
|
|
monospace: [
|
|
{ family: "JetBrains Mono", category: "monospace", variants: ["100", "200", "300", "400", "500", "600", "700", "800"], variable: true },
|
|
{ family: "Fira Code", category: "monospace", variants: ["300", "400", "500", "600", "700"], variable: true },
|
|
{ family: "Source Code Pro", category: "monospace", variants: ["200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Roboto Mono", category: "monospace", variants: ["100", "200", "300", "400", "500", "600", "700"], variable: true },
|
|
{ family: "IBM Plex Mono", category: "monospace", variants: ["100", "200", "300", "400", "500", "600", "700"], variable: false },
|
|
{ family: "Space Mono", category: "monospace", variants: ["400", "700"], variable: false },
|
|
{ family: "Ubuntu Mono", category: "monospace", variants: ["400", "700"], variable: false },
|
|
{ family: "Inconsolata", category: "monospace", variants: ["200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Geist Mono", category: "monospace", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
|
|
{ family: "Anonymous Pro", category: "monospace", variants: ["400", "700"], variable: false },
|
|
{ family: "Red Hat Mono", category: "monospace", variants: ["300", "400", "500", "600", "700"], variable: true },
|
|
],
|
|
display: [
|
|
{ family: "Bebas Neue", category: "display", variants: ["400"], variable: false },
|
|
{ family: "Abril Fatface", category: "display", variants: ["400"], variable: false },
|
|
{ family: "Righteous", category: "display", variants: ["400"], variable: false },
|
|
{ family: "Fredoka", category: "display", variants: ["300", "400", "500", "600", "700"], variable: true },
|
|
{ family: "Lobster", category: "display", variants: ["400"], variable: false },
|
|
{ family: "Comfortaa", category: "display", variants: ["300", "400", "500", "600", "700"], variable: true },
|
|
{ family: "Alfa Slab One", category: "display", variants: ["400"], variable: false },
|
|
{ family: "Bungee", category: "display", variants: ["400"], variable: false },
|
|
{ family: "Lilita One", category: "display", variants: ["400"], variable: false },
|
|
{ family: "Permanent Marker", category: "display", variants: ["400"], variable: false },
|
|
],
|
|
handwriting: [
|
|
{ family: "Dancing Script", category: "handwriting", variants: ["400", "500", "600", "700"], variable: true },
|
|
{ family: "Pacifico", category: "handwriting", variants: ["400"], variable: false },
|
|
{ family: "Caveat", category: "handwriting", variants: ["400", "500", "600", "700"], variable: true },
|
|
{ family: "Satisfy", category: "handwriting", variants: ["400"], variable: false },
|
|
{ family: "Great Vibes", category: "handwriting", variants: ["400"], variable: false },
|
|
{ family: "Sacramento", category: "handwriting", variants: ["400"], variable: false },
|
|
{ family: "Kalam", category: "handwriting", variants: ["300", "400", "700"], variable: false },
|
|
{ family: "Patrick Hand", category: "handwriting", variants: ["400"], variable: false },
|
|
{ family: "Indie Flower", category: "handwriting", variants: ["400"], variable: false },
|
|
{ family: "Shadows Into Light", category: "handwriting", variants: ["400"], variable: false },
|
|
],
|
|
};
|
|
|
|
interface FontPickerProps {
|
|
value?: string;
|
|
category?: FilterFontCategory;
|
|
onSelect: (font: FontInfo) => void;
|
|
placeholder?: string;
|
|
className?: string;
|
|
}
|
|
|
|
function FontItem({
|
|
font,
|
|
isSelected,
|
|
isLoading,
|
|
onSelect,
|
|
selectedRef,
|
|
}: {
|
|
font: FontInfo;
|
|
isSelected: boolean;
|
|
isLoading: boolean;
|
|
onSelect: (font: FontInfo) => void;
|
|
selectedRef: React.Ref<HTMLDivElement> | null;
|
|
}) {
|
|
const fontFamily = buildFontFamily(font.family, font.category);
|
|
|
|
return (
|
|
<CommandItem
|
|
className="flex cursor-pointer items-center justify-between gap-2 p-2"
|
|
onSelect={() => onSelect(font)}
|
|
disabled={isLoading}
|
|
onMouseEnter={() => loadGoogleFont(font.family, ["400"])}
|
|
ref={selectedRef}
|
|
>
|
|
<div className="line-clamp-1 inline-flex w-full flex-1 flex-col justify-between">
|
|
<span className="inline-flex items-center gap-2 truncate" style={{ fontFamily }}>
|
|
{font.family}
|
|
{isLoading && <Loader2 className="size-3 animate-spin" />}
|
|
</span>
|
|
<div className="flex items-center gap-1 text-xs font-normal opacity-70">
|
|
<span>{font.category}</span>
|
|
{font.variable && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<span>•</span>
|
|
<span>Variable</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isSelected && <Check className="size-4 shrink-0 opacity-70" />}
|
|
</CommandItem>
|
|
);
|
|
}
|
|
|
|
export function FontPicker({
|
|
value,
|
|
category,
|
|
onSelect,
|
|
placeholder = "Search fonts...",
|
|
className,
|
|
}: FontPickerProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [selectedCategory, setSelectedCategory] = useState<FilterFontCategory>(category || "all");
|
|
const [loadingFont, setLoadingFont] = useState<string | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const selectedFontRef = useRef<HTMLDivElement>(null);
|
|
const hasScrolledToSelectedFont = useRef(false);
|
|
|
|
const debouncedSetSearchQuery = useDebouncedCallback(setSearchQuery, 300);
|
|
|
|
useEffect(() => {
|
|
debouncedSetSearchQuery(inputValue);
|
|
}, [inputValue, debouncedSetSearchQuery]);
|
|
|
|
const fontQuery = useFontSearch({
|
|
query: searchQuery,
|
|
category: selectedCategory,
|
|
limit: 15,
|
|
enabled: open,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
scrollRef.current?.scrollTo({ top: 0 });
|
|
}, [selectedCategory, searchQuery, open]);
|
|
|
|
useEffect(() => {
|
|
if (open && fontQuery.data && !hasScrolledToSelectedFont.current) {
|
|
requestAnimationFrame(() => {
|
|
selectedFontRef.current?.scrollIntoView({
|
|
block: "center",
|
|
inline: "nearest",
|
|
});
|
|
});
|
|
hasScrolledToSelectedFont.current = true;
|
|
} else if (!open) {
|
|
hasScrolledToSelectedFont.current = false;
|
|
}
|
|
}, [open, fontQuery.data]);
|
|
|
|
// Flatten all pages into a single array
|
|
const allFonts = useMemo(() => {
|
|
if (!fontQuery.data) return [];
|
|
return fontQuery.data.pages.flatMap((page) => page.fonts);
|
|
}, [fontQuery.data]);
|
|
|
|
// Popular fonts for the current category (only shown when not searching)
|
|
const popularFonts = useMemo(() => {
|
|
if (searchQuery) return [];
|
|
if (selectedCategory === "all") {
|
|
// Show a mix from sans-serif, serif, monospace
|
|
return [
|
|
...POPULAR_FONTS["sans-serif"].slice(0, 2),
|
|
...POPULAR_FONTS["serif"].slice(0, 1),
|
|
...POPULAR_FONTS["monospace"].slice(0, 1),
|
|
];
|
|
}
|
|
return POPULAR_FONTS[selectedCategory] || [];
|
|
}, [searchQuery, selectedCategory]);
|
|
|
|
// Filter out popular fonts from the main list to avoid duplicates
|
|
const remainingFonts = useMemo(() => {
|
|
if (popularFonts.length === 0) return allFonts;
|
|
const popularFamilies = new Set(popularFonts.map((f) => f.family));
|
|
return allFonts.filter((font) => !popularFamilies.has(font.family));
|
|
}, [allFonts, popularFonts]);
|
|
|
|
// Intersection Observer ref callback for infinite scroll
|
|
const loadMoreRefCallback = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
if (!node) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const entry = entries[0];
|
|
if (entry.isIntersecting && fontQuery.hasNextPage && !fontQuery.isFetchingNextPage) {
|
|
fontQuery.fetchNextPage();
|
|
}
|
|
},
|
|
{
|
|
root: scrollRef.current,
|
|
rootMargin: "100px",
|
|
threshold: 0,
|
|
}
|
|
);
|
|
|
|
observer.observe(node);
|
|
return () => observer.unobserve(node);
|
|
},
|
|
[fontQuery.hasNextPage, fontQuery.isFetchingNextPage, fontQuery.fetchNextPage]
|
|
);
|
|
|
|
const handleFontSelect = useCallback(
|
|
async (font: FontInfo) => {
|
|
setLoadingFont(font.family);
|
|
|
|
try {
|
|
const weights = getDefaultWeights(font.variants);
|
|
loadGoogleFont(font.family, weights);
|
|
await waitForFont(font.family, weights[0]);
|
|
} catch (error) {
|
|
console.warn(`Failed to load font ${font.family}:`, error);
|
|
}
|
|
|
|
setLoadingFont(null);
|
|
onSelect(font);
|
|
},
|
|
[onSelect]
|
|
);
|
|
|
|
// Get current font info for display
|
|
const currentFont = useMemo(() => {
|
|
if (!value) return null;
|
|
|
|
// First try to find the font in the search results
|
|
const foundFont = allFonts.find((font: FontInfo) => font.family === value);
|
|
if (foundFont) return foundFont;
|
|
|
|
// If not found in search results, create a fallback FontInfo object
|
|
// This happens when a font is selected and then the search changes
|
|
const extractedFontName = value.split(",")[0].trim().replace(/['"]/g, "");
|
|
|
|
return {
|
|
family: extractedFontName,
|
|
category: category || "sans-serif",
|
|
variants: ["400"],
|
|
variable: false,
|
|
} as FontInfo;
|
|
}, [value, allFonts, category]);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
className={cn("bg-input/25 w-full justify-between", className)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{currentFont ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<span
|
|
style={{
|
|
fontFamily: buildFontFamily(currentFont.family, currentFont.category),
|
|
}}
|
|
>
|
|
{currentFont.family}
|
|
</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">{placeholder}</span>
|
|
)}
|
|
</div>
|
|
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent className="w-[300px] p-0" align="start">
|
|
<Command shouldFilter={false} className="h-96 w-full overflow-hidden">
|
|
<div className="flex flex-col">
|
|
<div className="relative">
|
|
<CommandInput
|
|
className="h-10 w-full border-none p-0 pr-10"
|
|
placeholder="Search Google fonts..."
|
|
value={inputValue}
|
|
onValueChange={setInputValue}
|
|
/>
|
|
|
|
{inputValue && (
|
|
<TooltipWrapper asChild label="Clear search">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setInputValue("")}
|
|
className="absolute top-2 right-2 size-6"
|
|
>
|
|
<FunnelX className="size-4" />
|
|
</Button>
|
|
</TooltipWrapper>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-2 py-1">
|
|
<Select
|
|
value={selectedCategory}
|
|
onValueChange={(value) => setSelectedCategory(value as FilterFontCategory)}
|
|
>
|
|
<SelectTrigger size="sm" className="focus bg-input/25 px-2 text-xs outline-none">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Fonts</SelectItem>
|
|
<SelectItem value="sans-serif">Sans Serif Fonts</SelectItem>
|
|
<SelectItem value="serif">Serif Fonts</SelectItem>
|
|
<SelectItem value="monospace">Monospace Fonts</SelectItem>
|
|
<SelectItem value="display">Display Fonts</SelectItem>
|
|
<SelectItem value="handwriting">Handwriting Fonts</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="relative isolate size-full">
|
|
{fontQuery.isLoading ? (
|
|
<div className="absolute inset-0 flex size-full items-center justify-center gap-2 text-center">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
<span className="text-muted-foreground text-sm">Loading fonts...</span>
|
|
</div>
|
|
) : allFonts.length === 0 ? (
|
|
<CommandEmpty>No fonts found.</CommandEmpty>
|
|
) : (
|
|
<CommandList className="scrollbar-thin size-full p-1" ref={scrollRef}>
|
|
{popularFonts.length > 0 && (
|
|
<CommandGroup heading="Popular">
|
|
{popularFonts.map((font) => (
|
|
<FontItem
|
|
key={font.family}
|
|
font={font}
|
|
isSelected={font.family === value}
|
|
isLoading={loadingFont === font.family}
|
|
onSelect={handleFontSelect}
|
|
selectedRef={font.family === value ? selectedFontRef : null}
|
|
/>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
<CommandGroup heading={popularFonts.length > 0 ? "All Fonts" : undefined}>
|
|
{remainingFonts.map((font: FontInfo) => (
|
|
<FontItem
|
|
key={font.family}
|
|
font={font}
|
|
isSelected={font.family === value}
|
|
isLoading={loadingFont === font.family}
|
|
onSelect={handleFontSelect}
|
|
selectedRef={font.family === value ? selectedFontRef : null}
|
|
/>
|
|
))}
|
|
</CommandGroup>
|
|
|
|
{/* Load more trigger element */}
|
|
{fontQuery.hasNextPage && <div ref={loadMoreRefCallback} className="h-2 w-full" />}
|
|
|
|
{/* Loading indicator for infinite scroll */}
|
|
{fontQuery.isFetchingNextPage && (
|
|
<div className="flex items-center justify-center gap-2 p-2">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
<span className="text-muted-foreground text-sm">Loading more fonts...</span>
|
|
</div>
|
|
)}
|
|
</CommandList>
|
|
)}
|
|
</div>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|