Skip to content

feat: Added hotkey shortcut to change themes #170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ff97b1b
feat: addedd hotkey on the app which invovles on the press of spaceba…
aa5hi5h Jul 18, 2025
2a2b4ce
fix: corrected the setIsClient typo in useClient and addedd the useMe…
aa5hi5h Jul 18, 2025
7bb0ff2
Fix SVG issue when processed by the API and minor styles fixes (#171)
llanesluis Jul 18, 2025
0a3b318
fix: build err
jnsahaj Jul 18, 2025
9bb3427
change AI related constants to optimise pricing
jnsahaj Jul 19, 2025
2d9e216
limit thinking budget to optimise pricing
jnsahaj Jul 19, 2025
5336cfa
update metadata and sitemap for indexable pages
jnsahaj Jul 19, 2025
4d2ec4d
fix: pricing page metadata and add temp launch50 badge
jnsahaj Jul 19, 2025
05d4222
fix: pro sub feature duplication
jnsahaj Jul 20, 2025
7af28a0
refactor(404): make 404 page more fun and interactive
fakhrirafiki Jul 20, 2025
c1c65db
Feature/testimonials (#176)
jnsahaj Jul 20, 2025
4440cb2
add more touchpoints for pricing page
jnsahaj Jul 21, 2025
7d6e4ce
remove Sponsor button
jnsahaj Jul 21, 2025
d5ed827
remove promo badge from pricing page
jnsahaj Jul 21, 2025
1b85d62
update sparkle icon for control panel
jnsahaj Jul 21, 2025
88261b5
Console+hydration errors (#178)
Abhi1264 Jul 21, 2025
cbd0ac4
Revert "Console+hydration errors (#178)"
jnsahaj Jul 22, 2025
cde4b7a
Fix Hydration Errors and Improve Preview Panel Defaults (#181)
llanesluis Jul 22, 2025
82c7d1e
fix: add min-w-[1300px] back to large Dashboard and Mail (#182)
llanesluis Jul 22, 2025
8c107dd
Feature/dynamic fonts (#197)
jnsahaj Jul 27, 2025
e15a7a2
feat: addedd hotkey on the app which invovles on the press of spaceba…
aa5hi5h Jul 18, 2025
44d0915
fix: corrected the setIsClient typo in useClient and addedd the useMe…
aa5hi5h Jul 18, 2025
a891bcd
some merge conflicts
aa5hi5h Aug 5, 2025
2dbbea7
Merge branch 'feature/theme-hotkey-generation' of https://github.com/…
aa5hi5h Aug 5, 2025
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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ GOOGLE_CLIENT_SECRET="YOUR_GOOGLE_CLIENT_SECRET"
# Get a Google API Key: https://aistudio.google.com/apikey
GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
# Get a Groq API Key: https://console.groq.com/keys
GROQ_API_KEY="YOUR_GROQ_API_KEY"
GROQ_API_KEY="YOUR_GROQ_API_KEY"

###### GOOGLE FONTS ######
# Get a Google Fonts API Key: https://developers.google.com/fonts/docs/developer_api
GOOGLE_FONTS_API_KEY="YOUR_GOOGLE_FONTS_API_KEY"
17 changes: 12 additions & 5 deletions app/ai/components/ai-announcement.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
"use client";

import { useSubscription } from "@/hooks/use-subscription";
import { ArrowRight } from "lucide-react";
import Link from "next/link";

export function AIAnnouncement() {
const { subscriptionStatus, isPending } = useSubscription();
const isPro = subscriptionStatus?.isSubscribed ?? false;

if (isPending || isPro) {
return null;
}

return (
<div className="mx-auto max-w-3xl">
<Link
href={"/editor/theme?tab=ai"}
href="/pricing"
className="group bg-muted flex items-center justify-between gap-2 rounded-full px-2 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md"
>
<span className="bg-primary text-primary-foreground rounded-full px-1.5 py-0.5 text-xs font-medium capitalize">
Beta
</span>
<span className="text-muted-foreground group-hover:text-foreground text-sm font-medium transition-colors">
Try the new AI Theme Editor
Upgrade to Pro for unlimited requests
</span>

<ArrowRight className="text-muted-foreground group-hover:text-foreground size-4 -rotate-45 transition-all group-hover:rotate-0" />
Expand Down
6 changes: 3 additions & 3 deletions app/ai/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { AIAnnouncement } from "./components/ai-announcement";
import { AIChatHero } from "./components/ai-chat-hero";

export const metadata: Metadata = {
title: "AI Theme Editor for shadcn/ui — tweakcn",
title: "Image to shadcn/ui theme. Generate with AI — tweakcn",
description:
"Effortlessly customize and generate shadcn/ui themes using tweakcn's AI-powered editor. Describe your desired theme, and let AI bring it to life. Supports Tailwind CSS, custom styles, and real-time previews.",
"Transform images into stunning shadcn/ui themes instantly with tweakcn's AI theme generator. Upload any image or describe your vision—our AI creates custom Tailwind CSS themes with real-time preview. Perfect for developers who want beautiful, production-ready themes in seconds.",
keywords:
"ai theme editor, shadcn/ui, tailwind css, theme generator, ai design, ui customization, tweakcn, AI assisted theming, frontend development, web design AI",
"ai theme generator, image to theme, shadcn/ui themes, tailwind css generator, ai design tool, theme from image, ui customization, tweakcn, visual theme creator, color palette generator, design system ai, frontend theming, web design automation",
robots: "index, follow",
};

Expand Down
9 changes: 8 additions & 1 deletion app/api/generate-theme/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getCurrentUserId, logError } from "@/lib/shared";
import { validateSubscriptionAndUsage } from "@/lib/subscription";
import { SubscriptionRequiredError } from "@/types/errors";
import { requestSchema, responseSchema, SYSTEM_PROMPT } from "@/utils/ai/generate-theme";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createGoogleGenerativeAI, GoogleGenerativeAIProviderOptions } from "@ai-sdk/google";
import { Ratelimit } from "@upstash/ratelimit";
import { kv } from "@vercel/kv";
import { generateText, Output } from "ai";
Expand Down Expand Up @@ -61,6 +61,13 @@ export async function POST(req: NextRequest) {
system: SYSTEM_PROMPT,
messages,
abortSignal: req.signal,
providerOptions: {
google: {
thinkingConfig: {
thinkingBudget: 128,
},
} satisfies GoogleGenerativeAIProviderOptions,
},
});

if (usage) {
Expand Down
54 changes: 54 additions & 0 deletions app/api/google-fonts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { PaginatedFontsResponse } from "@/types/fonts";
import { FALLBACK_FONTS } from "@/utils/fonts";
import { fetchGoogleFonts } from "@/utils/fonts/google-fonts";
import { unstable_cache } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

const cachedFetchGoogleFonts = unstable_cache(fetchGoogleFonts, ["google-fonts-catalogue"], {
tags: ["google-fonts-catalogue"],
});
Comment on lines +7 to +9
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consider using stable caching alternatives

The unstable_cache API is experimental and may change or be removed in future Next.js versions. Consider using a stable caching solution like Redis, in-memory cache with LRU, or Next.js ISR for production stability.

🤖 Prompt for AI Agents
In app/api/google-fonts/route.ts around lines 7 to 9, the use of the
experimental unstable_cache API poses a risk for future compatibility. Replace
unstable_cache with a stable caching solution such as Redis, an in-memory LRU
cache, or implement Next.js Incremental Static Regeneration (ISR) to ensure
production stability and maintainability.


export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q")?.toLowerCase() || "";
const category = searchParams.get("category")?.toLowerCase();
const limit = Math.min(Number(searchParams.get("limit")) || 50, 100);
const offset = Number(searchParams.get("offset")) || 0;
Comment on lines +14 to +17
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Add input validation for query parameters

The query parameter handling could be more robust. Number() returns NaN for invalid inputs, and optional chaining on searchParams.get() might not handle all edge cases.

-    const query = searchParams.get("q")?.toLowerCase() || "";
-    const category = searchParams.get("category")?.toLowerCase();
-    const limit = Math.min(Number(searchParams.get("limit")) || 50, 100);
-    const offset = Number(searchParams.get("offset")) || 0;
+    const query = (searchParams.get("q") || "").toLowerCase();
+    const category = searchParams.get("category")?.toLowerCase();
+    const limitParam = searchParams.get("limit");
+    const offsetParam = searchParams.get("offset");
+    
+    const limit = Math.min(
+      limitParam && !isNaN(Number(limitParam)) ? Number(limitParam) : 50,
+      100
+    );
+    const offset = offsetParam && !isNaN(Number(offsetParam)) ? Number(offsetParam) : 0;
📝 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
const query = searchParams.get("q")?.toLowerCase() || "";
const category = searchParams.get("category")?.toLowerCase();
const limit = Math.min(Number(searchParams.get("limit")) || 50, 100);
const offset = Number(searchParams.get("offset")) || 0;
const query = (searchParams.get("q") || "").toLowerCase();
const category = searchParams.get("category")?.toLowerCase();
const limitParam = searchParams.get("limit");
const offsetParam = searchParams.get("offset");
const limit = Math.min(
limitParam && !isNaN(Number(limitParam)) ? Number(limitParam) : 50,
100
);
const offset = offsetParam && !isNaN(Number(offsetParam)) ? Number(offsetParam) : 0;
🤖 Prompt for AI Agents
In app/api/google-fonts/route.ts around lines 14 to 17, improve input validation
for query parameters by explicitly checking if the values retrieved from
searchParams are valid before converting them. For numeric parameters like limit
and offset, verify that the parsed numbers are not NaN and fall within expected
ranges, defaulting to safe values if invalid. For string parameters like query
and category, ensure they are non-null strings before applying toLowerCase,
handling cases where searchParams.get() returns null or unexpected types.


let googleFonts = FALLBACK_FONTS;

try {
googleFonts = await cachedFetchGoogleFonts(process.env.GOOGLE_FONTS_API_KEY);
} catch (error) {
console.error("Error fetching Google Fonts:", error);
console.log("Using fallback fonts");
}

// Filter fonts based on search query and category
let filteredFonts = googleFonts;

if (query) {
filteredFonts = filteredFonts.filter((font) => font.family.toLowerCase().includes(query));
}

if (category && category !== "all") {
filteredFonts = filteredFonts.filter((font) => font.category === category);
}

const paginatedFonts = filteredFonts.slice(offset, offset + limit);

const response: PaginatedFontsResponse = {
fonts: paginatedFonts,
total: filteredFonts.length,
offset,
limit,
hasMore: offset + limit < filteredFonts.length,
};

return NextResponse.json(response);
} catch (error) {
console.error("Error in Google Fonts API:", error);
return NextResponse.json({ error: "Failed to fetch fonts" }, { status: 500 });
}
}
1 change: 1 addition & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
}

* {
color-scheme: light dark;
border-color: var(--color-border);
}

Expand Down
4 changes: 4 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AuthDialogWrapper } from "@/components/auth-dialog-wrapper";
import { DynamicFontLoader } from "@/components/dynamic-font-loader";
import { GetProDialogWrapper } from "@/components/get-pro-dialog-wrapper";
import { PostHogInit } from "@/components/posthog-init";
import { ThemeProvider } from "@/components/theme-provider";
Expand Down Expand Up @@ -54,6 +55,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en">
<head>
<ThemeScript />
<DynamicFontLoader />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
Expand All @@ -65,6 +67,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
sizes="180x180"
/>
<link rel="manifest" href="/site.webmanifest" />

{/* PRELOAD FONTS USED BY BUILT-IN THEMES */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
Expand Down
60 changes: 57 additions & 3 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,62 @@
"use client";

import { ThemePresetButtons } from "@/components/home/theme-preset-buttons";
import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useEditorStore } from "@/store/editor-store";
import { defaultPresets } from "@/utils/theme-presets";
import { Sun, Moon } from "lucide-react";
import Link from "next/link";

export default function NotFound() {
const { theme, toggleTheme } = useTheme();
const { themeState, applyThemePreset } = useEditorStore();
const mode = themeState.currentMode;
const presetNames = Object.keys(defaultPresets);
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<h1 className="text-4xl font-bold">404</h1>
<p className="mt-4 text-lg">Page not found</p>
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4">
<div className="fixed top-4 right-4 z-50">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Toggle theme"
onClick={(e) => toggleTheme({ x: e.clientX, y: e.clientY })}
>
{theme === "light" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">Toggle theme</p>
</TooltipContent>
</Tooltip>
</div>

<span className="text-muted-foreground mb-6 text-[6rem] leading-none font-extrabold select-none">
404
</span>
<h1 className="text-foreground mb-2 text-3xl font-bold">Oops, Lost in Space?</h1>
<p className="text-muted-foreground mb-8 max-w-md text-center text-lg">
Go home or try switching the theme!
</p>

<Link
href="/"
className="bg-primary text-primary-foreground hover:bg-primary/80 mb-10 rounded-md px-6 py-2 font-semibold shadow transition-colors"
>
Back to Home
</Link>

<div className="flex w-full justify-center">
<ThemePresetButtons
presetNames={presetNames}
mode={mode}
themeState={themeState}
applyThemePreset={applyThemePreset}
/>
</div>
</div>
);
}
5 changes: 4 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Header } from "@/components/home/header";
import { Hero } from "@/components/home/hero";
import { HowItWorks } from "@/components/home/how-it-works";
import { Roadmap } from "@/components/home/roadmap";
import { ThemeHotKeyHandler } from "@/components/home/theme-hotkey-handler";
import { ThemePresetSelector } from "@/components/home/theme-preset-selector";
import { useEffect, useState } from "react";

Expand All @@ -18,7 +19,7 @@ export default function Home() {

useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 10) {
if (window.scrollY > 10){
setIsScrolled(true);
} else {
setIsScrolled(false);
Expand All @@ -31,6 +32,7 @@ export default function Home() {

return (
<div className="bg-background text-foreground flex min-h-[100dvh] flex-col items-center justify-items-center">
<ThemeHotKeyHandler>
<Header
isScrolled={isScrolled}
mobileMenuOpen={mobileMenuOpen}
Expand All @@ -47,6 +49,7 @@ export default function Home() {
<CTA />
</main>
<Footer />
</ThemeHotKeyHandler>
</div>
);
}
14 changes: 11 additions & 3 deletions app/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import { NoiseEffect } from "@/components/effects/noise-effect";
import {
Accordion,
Expand All @@ -15,6 +13,13 @@ import { FREE_SUB_FEATURES, PRO_SUB_FEATURES } from "@/utils/subscription";
import { Calendar, Check, Circle, Mail } from "lucide-react";
import Link from "next/link";
import { CheckoutButton } from "./components/checkout-button";
import { Metadata } from "next";
import { Testimonials } from "@/components/home/testimonials";

export const metadata: Metadata = {
title: "Pricing — tweakcn",
robots: "index, follow",
};

export default function PricingPage() {
return (
Expand Down Expand Up @@ -131,6 +136,10 @@ export default function PricingPage() {
</Card>
</section>

<div className="-mt-8">
<Testimonials />
</div>

{/* FAQs Section */}
<section className="mx-auto max-w-3xl space-y-8">
<div className="space-y-2 text-center">
Expand Down Expand Up @@ -161,7 +170,6 @@ export default function PricingPage() {
))}
</Accordion>
</section>

{/* Bottom Section */}
<div className="text-center">
<div className="mx-auto max-w-2xl space-y-2">
Expand Down
6 changes: 6 additions & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "weekly",
priority: 0.8,
},
{
url: `${baseUrl}/pricing`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.6,
},
];
}
42 changes: 42 additions & 0 deletions components/dynamic-font-loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { useMounted } from "@/hooks/use-mounted";
import { useEditorStore } from "@/store/editor-store";
import { extractFontFamily, getDefaultWeights } from "@/utils/fonts";
import { loadGoogleFont } from "@/utils/fonts/google-fonts";
import { useEffect, useMemo } from "react";

export function DynamicFontLoader() {
const { themeState } = useEditorStore();
const isMounted = useMounted();

const fontSans = themeState.styles.light["font-sans"];
const fontSerif = themeState.styles.light["font-serif"];
const fontMono = themeState.styles.light["font-mono"];
Comment on lines +13 to +15
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consider loading fonts from both light and dark theme styles.

Currently, only fonts from the light theme are loaded. If dark and light themes use different fonts, users switching themes might experience loading delays or missing fonts.

Consider loading fonts from both theme modes:

  const fontSans = themeState.styles.light["font-sans"];
  const fontSerif = themeState.styles.light["font-serif"];
  const fontMono = themeState.styles.light["font-mono"];
+ const darkFontSans = themeState.styles.dark["font-sans"];
+ const darkFontSerif = themeState.styles.dark["font-serif"];
+ const darkFontMono = themeState.styles.dark["font-mono"];
🤖 Prompt for AI Agents
In components/dynamic-font-loader.tsx around lines 13 to 15, the code only loads
fonts from the light theme styles, which can cause issues when switching to dark
theme if it uses different fonts. Update the code to also load fonts from the
dark theme styles by accessing themeState.styles.dark for "font-sans",
"font-serif", and "font-mono" and ensure both sets of fonts are included to
prevent loading delays or missing fonts when switching themes.


const currentFonts = useMemo(() => {
return {
sans: fontSans,
serif: fontSerif,
mono: fontMono,
} as const;
}, [fontSans, fontSerif, fontMono]);

useEffect(() => {
if (!isMounted) return;

try {
Object.entries(currentFonts).forEach(([_type, fontValue]) => {
const fontFamily = extractFontFamily(fontValue);
if (fontFamily) {
const weights = getDefaultWeights(["400", "500", "600", "700"]);
loadGoogleFont(fontFamily, weights);
}
});
} catch (e) {
console.warn("DynamicFontLoader: Failed to load Google fonts:", e);
}
}, [isMounted, currentFonts]);

return null;
}
Loading