├── .gitignore
├── LICENSE
├── README.md
├── app
├── components
│ ├── splash-screen-wrapper.tsx
│ └── splash-screen.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── opengraph-image.png
├── page.tsx
└── pwa.ts
├── assets
├── index.tsx
└── logo.svg
├── components.json
├── components
├── core-ui
│ ├── canvas-preview.tsx
│ ├── desktop-app.tsx
│ ├── footer.tsx
│ ├── mobile-app.tsx
│ └── wallpaper-preview.tsx
├── drawer-content
│ └── settings-drawer-content.tsx
├── theme-provider.tsx
└── ui
│ ├── animatedGradient.tsx
│ ├── button.tsx
│ ├── buttonsChin.tsx
│ ├── dialog.tsx
│ ├── drawer.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── marquee.tsx
│ ├── popover.tsx
│ ├── position-control.tsx
│ ├── scroll-area.tsx
│ ├── select.tsx
│ ├── separator.tsx
│ ├── sidebarHeader.tsx
│ ├── slider.tsx
│ ├── sonner.tsx
│ ├── switch.tsx
│ ├── tabs.tsx
│ ├── textarea.tsx
│ └── themeSwitch.tsx
├── eslint.config.mjs
├── hooks
├── use-debounced-dimensions.ts
└── use-safari-check.ts
├── lib
├── constants.ts
├── fonts.ts
├── icons
│ ├── github.tsx
│ └── twitter.tsx
├── utils.ts
└── utils
│ ├── effects.ts
│ └── shapes.ts
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── public
├── icons
│ ├── logo-192.png
│ └── logo-512.png
├── logo.svg
├── manifest.json
└── service-worker.js
├── store
└── wallpaper.ts
├── tailwind.config.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2025 Keshav Bagaade
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Gradii
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | 
11 |
12 |
13 | Create beautiful mesh gradients at https://gradii.fun.
14 |
15 |
16 | ## Why Gradii?
17 |
18 | I use mesh gradients as wallpapers and most of these gradients are locked behind paywalls or not free to use. So I built Gradii - a simple tool that could generate cool gradients for myself and others who love minimal, beautiful wallpapers. What started as a personal project turned into something I wanted to share with everyone who appreciates good design.
19 |
20 | ## What can Gradii do?
21 |
22 | - Create stunning mesh gradients
23 | - Add text overlays with various fonts and styles
24 | - Add image overlays
25 | - Use it as your wallpaper or as a marketing asset the possibilities are wide
26 | - Export in multiple resolutions and aspect ratios
27 |
28 | ## Development
29 |
30 | ```bash
31 | cd wallpaper-app
32 | pnpm install
33 | pnpm dev
34 | ```
35 |
36 | ## License & Contributing
37 |
38 | This project uses the MIT license. See the [LICENSE](LICENSE) file for details.
39 |
40 | ## Contact & Support
41 |
42 | Found a bug or have feedback? Feel free to [DM me on X](https://x.com/kshvbgde).
43 |
--------------------------------------------------------------------------------
/app/components/splash-screen-wrapper.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect, Suspense } from "react";
4 | import { SplashScreen } from "./splash-screen";
5 | import { AnimatePresence } from "framer-motion";
6 | import { Loader2Icon } from "lucide-react";
7 | interface SplashScreenWrapperProps {
8 | children: React.ReactNode;
9 | }
10 |
11 | export function SplashScreenWrapper({ children }: SplashScreenWrapperProps) {
12 | const [showSplash, setShowSplash] = useState(true);
13 |
14 | useEffect(() => {
15 | const timer = setTimeout(() => {
16 | setShowSplash(false);
17 | }, 2000);
18 |
19 | return () => clearTimeout(timer);
20 | }, []);
21 |
22 | return (
23 | <>
24 |
25 |
28 | }
29 | >
30 | {showSplash ? : children}
31 |
32 |
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/app/components/splash-screen.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import Image from "next/image";
5 | import logo from "@/public/logo.svg";
6 |
7 | export function SplashScreen() {
8 | return (
9 |
15 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keshav-exe/wallpaper-app/eb7573a157d541e81c76800f216f03bf30d61ee1/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @plugin 'tailwindcss-animate';
4 |
5 | @custom-variant dark (&:is(.dark *));
6 |
7 | @theme {
8 | --color-background: hsl(var(--background));
9 | --color-foreground: hsl(var(--foreground));
10 |
11 | --color-card: hsl(var(--card));
12 | --color-card-foreground: hsl(var(--card-foreground));
13 |
14 | --color-popover: hsl(var(--popover));
15 | --color-popover-foreground: hsl(var(--popover-foreground));
16 |
17 | --color-primary: hsl(var(--primary));
18 | --color-primary-foreground: hsl(var(--primary-foreground));
19 |
20 | --color-secondary: hsl(var(--secondary));
21 | --color-secondary-foreground: hsl(var(--secondary-foreground));
22 |
23 | --color-muted: hsl(var(--muted));
24 | --color-muted-foreground: hsl(var(--muted-foreground));
25 |
26 | --color-accent: hsl(var(--accent));
27 | --color-accent-foreground: hsl(var(--accent-foreground));
28 |
29 | --color-destructive: hsl(var(--destructive));
30 | --color-destructive-foreground: hsl(var(--destructive-foreground));
31 |
32 | --color-border: hsl(var(--border));
33 | --color-input: hsl(var(--input));
34 | --color-ring: hsl(var(--ring));
35 |
36 | --color-chart-1: hsl(var(--chart-1));
37 | --color-chart-2: hsl(var(--chart-2));
38 | --color-chart-3: hsl(var(--chart-3));
39 | --color-chart-4: hsl(var(--chart-4));
40 | --color-chart-5: hsl(var(--chart-5));
41 |
42 | --radius-lg: var(--radius);
43 | --radius-md: calc(var(--radius) - 2px);
44 | --radius-sm: calc(var(--radius) - 4px);
45 |
46 | --animate-marquee: marquee var(--duration) infinite linear;
47 | --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
48 |
49 | @keyframes marquee {
50 | from {
51 | transform: translateX(0);
52 | }
53 | to {
54 | transform: translateX(calc(-100% - var(--gap)));
55 | }
56 | }
57 | @keyframes marquee-vertical {
58 | from {
59 | transform: translateY(0);
60 | }
61 | to {
62 | transform: translateY(calc(-100% - var(--gap)));
63 | }
64 | }
65 | }
66 |
67 | /*
68 | The default border color has changed to `currentColor` in Tailwind CSS v4,
69 | so we've added these compatibility styles to make sure everything still
70 | looks the same as it did with Tailwind CSS v3.
71 |
72 | If we ever want to remove these styles, we need to add an explicit border
73 | color utility to any element that depends on these defaults.
74 | */
75 | @layer base {
76 | *,
77 | ::after,
78 | ::before,
79 | ::backdrop,
80 | ::file-selector-button {
81 | border-color: var(--color-gray-200, currentColor);
82 | }
83 | }
84 |
85 | @layer base {
86 | :root {
87 | --background: 0 0% 100%;
88 | --foreground: 240 10% 3.9%;
89 | --card: 0 0% 100%;
90 | --card-foreground: 240 10% 3.9%;
91 | --popover: 0 0% 100%;
92 | --popover-foreground: 240 10% 3.9%;
93 | --primary: 240 5.9% 10%;
94 | --primary-foreground: 0 0% 98%;
95 | --secondary: 240 4.8% 95.9%;
96 | --secondary-foreground: 240 5.9% 10%;
97 | --muted: 240 4.8% 92.9%;
98 | --muted-foreground: 240 3.8% 46.1%;
99 | --accent: 240 4.8% 95.9%;
100 | --accent-foreground: 240 5.9% 10%;
101 | --destructive: 0 84.2% 60.2%;
102 | --destructive-foreground: 0 0% 98%;
103 | --border: 240 5.9% 90%;
104 | --input: 240 5.9% 90%;
105 | --ring: 240 5.9% 10%;
106 | --radius: 0.5rem;
107 | }
108 |
109 | .dark {
110 | --background: 240 10% 3.9%;
111 | --foreground: 0 0% 98%;
112 | --card: 240 10% 3.9%;
113 | --card-foreground: 0 0% 98%;
114 | --popover: 240 10% 3.9%;
115 | --popover-foreground: 0 0% 98%;
116 | --primary: 0 0% 98%;
117 | --primary-foreground: 240 5.9% 10%;
118 | --secondary: 240 3.7% 8%;
119 | --secondary-foreground: 0 0% 98%;
120 | --muted: 240 3.7% 6%;
121 | --muted-foreground: 240 5% 64.9%;
122 | --accent: 240 3.7% 8%;
123 | --accent-foreground: 0 0% 98%;
124 | --destructive: 0 62.8% 30.6%;
125 | --destructive-foreground: 0 0% 98%;
126 | --border: 240 3.7% 8%;
127 | --input: 240 3.7% 8%;
128 | --ring: 240 4.9% 83.9%;
129 | }
130 | }
131 |
132 | @layer base {
133 | * {
134 | @apply border-border;
135 | -webkit-user-select: none;
136 | -moz-user-select: none;
137 | -ms-user-select: none;
138 | user-select: none;
139 | }
140 | body {
141 | @apply bg-background text-foreground;
142 | }
143 | }
144 |
145 | .no-scrollbar::-webkit-scrollbar {
146 | display: none;
147 | }
148 |
149 | .no-scrollbar {
150 | scrollbar-width: none;
151 | -webkit-overflow-scrolling: touch;
152 | overflow-y: auto;
153 | }
154 |
155 | .dark ::selection {
156 | background-color: hsl(var(--accent));
157 | color: hsl(var(--foreground));
158 | }
159 |
160 | ::view-transition-group(root) {
161 | animation-duration: 0.7s;
162 | animation-timing-function: cubic-bezier(0.65, 0.05, 0.36, 1);
163 | }
164 |
165 | ::view-transition-new(root) {
166 | animation-name: reveal-light;
167 | }
168 |
169 | ::view-transition-old(root),
170 | .dark::view-transition-old(root) {
171 | animation: none;
172 | z-index: -1;
173 | }
174 | .dark::view-transition-new(root) {
175 | animation-name: reveal-dark;
176 | }
177 |
178 | @keyframes reveal-dark {
179 | from {
180 | clip-path: inset(0 100% 0 0);
181 | }
182 | to {
183 | clip-path: inset(0 0 0 0);
184 | }
185 | }
186 |
187 | @keyframes reveal-light {
188 | from {
189 | clip-path: inset(0 100% 0 0);
190 | }
191 | to {
192 | clip-path: inset(0 0 0 0);
193 | }
194 | }
195 |
196 | * {
197 | user-select: none;
198 | }
199 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import { onest } from "@/lib/fonts";
3 | import { Toaster } from "@/components/ui/sonner";
4 | import { ThemeProvider } from "@/components/theme-provider";
5 | import { Analytics } from "@vercel/analytics/react";
6 | import { SplashScreenWrapper } from "@/app/components/splash-screen-wrapper";
7 |
8 | export const metadata = {
9 | title: "Gradii - Generate Beautiful Gradients",
10 | description:
11 | "A simple gradient generator tool made by designer for designers to create stunning gradients with customizable colors, text, and effects. Use it for your designs, wallpapers, presentations, or mockups or just for fun.",
12 | metadataBase: new URL("https://gradii.fun"),
13 | keywords: [
14 | // Core Features
15 | "gradient generator",
16 | "gradient maker",
17 | "gradient creator",
18 | "gradient design tool",
19 | "gradient background maker",
20 | "gradient wallpaper creator",
21 |
22 | // Types & Use Cases
23 | "desktop wallpaper",
24 | "mobile wallpaper",
25 | "4K wallpaper",
26 | "custom wallpaper",
27 | "background generator",
28 | "website background",
29 | "presentation background",
30 | "social media background",
31 |
32 | // Technical Features
33 | "canvas effects",
34 | "grain effect",
35 | "vignette effect",
36 | "text effects",
37 | "color palette",
38 | "color picker",
39 | "hex color",
40 | "aspect ratio",
41 | "high resolution",
42 | "image filters",
43 |
44 | // Formats & Quality
45 | "HD wallpaper",
46 | "2K wallpaper",
47 | "4K resolution",
48 | "16:9 wallpaper",
49 | "9:16 wallpaper",
50 | "square wallpaper",
51 |
52 | // Descriptors
53 | "beautiful gradients",
54 | "modern gradients",
55 | "professional backgrounds",
56 | "custom design",
57 | "online tool",
58 | "free",
59 | "web-based",
60 | "browser-based",
61 | "no download required",
62 | "no installation required",
63 | "no signup required",
64 | "no login required",
65 | "no email required",
66 | "no password required",
67 | "no signup required",
68 |
69 | // Design Related
70 | "design tool",
71 | "graphic design",
72 | "web design",
73 | "UI design",
74 | "design resources",
75 | "design assets",
76 | ],
77 | authors: [{ name: "Keshav Bagaade", url: "https://keshavbagaade.com" }],
78 | creator: "Keshav Bagaade",
79 | openGraph: {
80 | type: "website",
81 | locale: "en_US",
82 | url: "https://gradii.fun",
83 | title: "Gradii - Generate Beautiful Gradients",
84 | description:
85 | "A simple gradient generator tool made by designer for designers to create stunning gradients with customizable colors, text, and effects. Use it for your designs, wallpapers, presentations, or mockups or just for fun.",
86 | siteName: "Gradii",
87 | images: ["/opengraph-image.png"],
88 | },
89 | twitter: {
90 | card: "summary_large_image",
91 | title: "Gradii - Generate Beautiful Gradients",
92 | description:
93 | "A simple gradient generator tool made by designer for designers to create stunning gradients with customizable colors, text, and effects. Use it for your designs, wallpapers, presentations, or mockups or just for fun.",
94 | creator: "@kshvbgde",
95 | images: ["/opengraph-image.png"],
96 | },
97 | robots: {
98 | index: true,
99 | follow: true,
100 | googleBot: {
101 | index: true,
102 | follow: true,
103 | "max-video-preview": -1,
104 | "max-image-preview": "large",
105 | "max-snippet": -1,
106 | },
107 | },
108 | verification: {
109 | google: "your-google-site-verification", // Add your verification code
110 | },
111 | alternates: {
112 | canonical: "https://gradii.fun",
113 | },
114 | category: "Design Tools",
115 | applicationName: "Gradii",
116 | manifest: "/manifest.json",
117 | appleWebApp: {
118 | capable: true,
119 | statusBarStyle: "default",
120 | themeColor: "transparent",
121 | title: "Gradii",
122 | },
123 | formatDetection: {
124 | telephone: false,
125 | },
126 | };
127 |
128 | export default function RootLayout({
129 | children,
130 | }: {
131 | children: React.ReactNode;
132 | }) {
133 | return (
134 |
135 |
136 |
140 |
145 |
152 |
153 |
158 |
163 |
164 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
177 |
178 |
184 | {children}
185 |
186 | {/* */}
187 |
188 |
189 |
190 | );
191 | }
192 |
--------------------------------------------------------------------------------
/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keshav-exe/wallpaper-app/eb7573a157d541e81c76800f216f03bf30d61ee1/app/opengraph-image.png
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import DesktopApp from "@/components/core-ui/desktop-app";
3 | import MobileApp from "@/components/core-ui/mobile-app";
4 | import { useEffect, useState } from "react";
5 | import { toast } from "sonner";
6 | import { FONTS } from "@/lib/constants";
7 | import { useWallpaperStore } from "@/store/wallpaper";
8 | import { useSafariCheck } from "@/hooks/use-safari-check";
9 |
10 | export default function Home() {
11 | const [isMobile, setIsMobile] = useState(false);
12 | const { isSafari, shouldShowPWAPrompt, dismissPWAPrompt } = useSafariCheck();
13 | const store = useWallpaperStore();
14 |
15 | useEffect(() => {
16 | const checkMobile = () => {
17 | setIsMobile(window.innerWidth < 768); // 768px is Tailwind's md breakpoint
18 | };
19 |
20 | checkMobile();
21 | window.addEventListener("resize", checkMobile);
22 | return () => window.removeEventListener("resize", checkMobile);
23 | }, []);
24 |
25 | useEffect(() => {
26 | const currentFont = FONTS.find((f) => f.name === store.fontFamily);
27 | if (!currentFont?.variable) {
28 | const availableWeights = currentFont?.weights || [];
29 | const closestWeight = availableWeights.reduce((prev, curr) =>
30 | Math.abs(curr - store.fontWeight) < Math.abs(prev - store.fontWeight)
31 | ? curr
32 | : prev
33 | );
34 | store.setFontWeight(closestWeight);
35 | }
36 | }, [store.fontFamily]);
37 |
38 | useEffect(() => {
39 | if (shouldShowPWAPrompt) {
40 | toast("Install our app for the best experience", {
41 | description: "Tap the share button and select 'Add to Home Screen'",
42 | duration: Infinity,
43 | closeButton: true,
44 | onDismiss: dismissPWAPrompt,
45 | });
46 | }
47 | }, [shouldShowPWAPrompt]);
48 |
49 | const downloadImage = async () => {
50 | try {
51 | const previewCanvas = document.querySelector(
52 | "#wallpaper canvas"
53 | ) as HTMLCanvasElement;
54 | if (!previewCanvas) return;
55 |
56 | const tempCanvas = document.createElement("canvas");
57 | tempCanvas.width = store.resolution.width;
58 | tempCanvas.height = store.resolution.height;
59 | const ctx = tempCanvas.getContext("2d")!;
60 |
61 | // Draw the preview canvas
62 | ctx.drawImage(
63 | previewCanvas,
64 | 0,
65 | 0,
66 | store.resolution.width,
67 | store.resolution.height
68 | );
69 |
70 | // Draw the text
71 | if (store.sizeMode === "text") {
72 | ctx.save();
73 |
74 | // Set font properties
75 | const fontString = `${store.isItalic ? "italic" : ""} ${
76 | store.fontWeight
77 | } ${store.fontSize * 16}px ${store.fontFamily}`;
78 | ctx.font = fontString;
79 | ctx.fillStyle = store.textColor;
80 | ctx.textAlign = store.textAlign as CanvasTextAlign;
81 | ctx.textBaseline = "middle";
82 | ctx.globalAlpha = store.opacity / 100;
83 |
84 | // Set text decorations
85 | if (store.textShadow.blur > 0) {
86 | ctx.shadowColor = store.textShadow.color;
87 | ctx.shadowBlur = store.textShadow.blur;
88 | ctx.shadowOffsetX = store.textShadow.offsetX;
89 | ctx.shadowOffsetY = store.textShadow.offsetY;
90 | }
91 |
92 | // Calculate text position
93 | let x = store.resolution.width / 2 + store.textPosition.x;
94 | if (store.textAlign === "left") {
95 | x = 20 + store.textPosition.x;
96 | } else if (store.textAlign === "right") {
97 | x = store.resolution.width - 20 + store.textPosition.x;
98 | }
99 |
100 | // Handle multiline text
101 | const lines = store.text.split("\n");
102 | const lineHeight = store.fontSize * 16 * store.lineHeight;
103 | const totalHeight = lines.length * lineHeight;
104 | const startY =
105 | store.resolution.height / 2 - totalHeight / 2 + store.textPosition.y;
106 |
107 | lines.forEach((line, index) => {
108 | const y = startY + index * lineHeight + lineHeight / 2;
109 | ctx.fillText(line, x, y);
110 |
111 | // Draw text decorations
112 | if (store.isUnderline || store.isStrikethrough) {
113 | const textMetrics = ctx.measureText(line);
114 | const textWidth = textMetrics.width;
115 | let decorationY = y;
116 |
117 | if (store.isUnderline) {
118 | decorationY = y + textMetrics.actualBoundingBoxDescent + 2;
119 | }
120 | if (store.isStrikethrough) {
121 | decorationY = y;
122 | }
123 |
124 | let startX = x;
125 | if (store.textAlign === "center") {
126 | startX = x - textWidth / 2;
127 | } else if (store.textAlign === "right") {
128 | startX = x - textWidth;
129 | }
130 |
131 | ctx.beginPath();
132 | ctx.strokeStyle = store.textColor;
133 | ctx.lineWidth = Math.max(1, store.fontSize * 0.05);
134 | ctx.moveTo(startX, decorationY);
135 | ctx.lineTo(startX + textWidth, decorationY);
136 | ctx.stroke();
137 | }
138 | });
139 |
140 | ctx.restore();
141 | }
142 |
143 | if (store.sizeMode === "image" && store.logoImage) {
144 | const img = new Image();
145 | await new Promise((resolve, reject) => {
146 | img.onload = resolve;
147 | img.onerror = reject;
148 | img.src = store.logoImage as string;
149 | });
150 |
151 | const maxWidth = store.resolution.width * 0.5;
152 | const maxHeight = store.resolution.height * 0.5;
153 | const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
154 | const width = img.width * scale;
155 | const height = img.height * scale;
156 |
157 | ctx.save();
158 | ctx.globalAlpha = store.opacity / 100;
159 | ctx.filter = `drop-shadow(${store.textShadow.offsetX}px ${store.textShadow.offsetY}px ${store.textShadow.blur}px ${store.textShadow.color})`;
160 | ctx.drawImage(
161 | img,
162 | store.resolution.width / 2 - width / 2 + store.textPosition.x,
163 | store.resolution.height / 2 - height / 2 + store.textPosition.y,
164 | width,
165 | height
166 | );
167 | ctx.restore();
168 | }
169 |
170 | // Handle download based on browser
171 | if (isSafari) {
172 | const dataUrl = tempCanvas.toDataURL("image/png");
173 | const link = document.createElement("a");
174 | link.href = dataUrl;
175 | link.download = `gradii-${store.resolution.width}x${store.resolution.height}.png`;
176 | link.click();
177 | } else {
178 | const blob = await new Promise((resolve) =>
179 | tempCanvas.toBlob((blob) => resolve(blob!), "image/png")
180 | );
181 | const url = URL.createObjectURL(blob);
182 | const link = document.createElement("a");
183 | link.href = url;
184 | link.download = `gradii-${store.resolution.width}x${store.resolution.height}.png`;
185 | document.body.appendChild(link);
186 | link.click();
187 | document.body.removeChild(link);
188 | URL.revokeObjectURL(url);
189 | }
190 |
191 | toast.success("Download will start shortly");
192 | } catch (err) {
193 | console.error(err);
194 | toast.error("Failed to download image");
195 | }
196 | };
197 |
198 | const copyImage = async () => {
199 | try {
200 | const previewCanvas = document.querySelector(
201 | "#wallpaper canvas"
202 | ) as HTMLCanvasElement;
203 | if (!previewCanvas) return;
204 |
205 | const tempCanvas = document.createElement("canvas");
206 | tempCanvas.width = store.resolution.width;
207 | tempCanvas.height = store.resolution.height;
208 | const ctx = tempCanvas.getContext("2d")!;
209 |
210 | // Draw the preview canvas
211 | ctx.drawImage(
212 | previewCanvas,
213 | 0,
214 | 0,
215 | store.resolution.width,
216 | store.resolution.height
217 | );
218 |
219 | // Handle text/logo drawing same as download
220 | if (store.sizeMode === "text") {
221 | ctx.save();
222 |
223 | // Set font properties
224 | const fontString = `${store.isItalic ? "italic" : ""} ${
225 | store.fontWeight
226 | } ${store.fontSize * 16}px ${store.fontFamily}`;
227 | ctx.font = fontString;
228 | ctx.fillStyle = store.textColor;
229 | ctx.textAlign = store.textAlign as CanvasTextAlign;
230 | ctx.textBaseline = "middle";
231 | ctx.globalAlpha = store.opacity / 100;
232 |
233 | // Set text decorations
234 | if (store.textShadow.blur > 0) {
235 | ctx.shadowColor = store.textShadow.color;
236 | ctx.shadowBlur = store.textShadow.blur;
237 | ctx.shadowOffsetX = store.textShadow.offsetX;
238 | ctx.shadowOffsetY = store.textShadow.offsetY;
239 | }
240 |
241 | // Calculate text position
242 | let x = store.resolution.width / 2 + store.textPosition.x;
243 | if (store.textAlign === "left") {
244 | x = 20 + store.textPosition.x;
245 | } else if (store.textAlign === "right") {
246 | x = store.resolution.width - 20 + store.textPosition.x;
247 | }
248 |
249 | // Handle multiline text
250 | const lines = store.text.split("\n");
251 | const lineHeight = store.fontSize * 16 * store.lineHeight;
252 | const totalHeight = lines.length * lineHeight;
253 | const startY =
254 | store.resolution.height / 2 - totalHeight / 2 + store.textPosition.y;
255 |
256 | lines.forEach((line, index) => {
257 | const y = startY + index * lineHeight + lineHeight / 2;
258 | ctx.fillText(line, x, y);
259 |
260 | // Draw text decorations
261 | if (store.isUnderline || store.isStrikethrough) {
262 | const textMetrics = ctx.measureText(line);
263 | const textWidth = textMetrics.width;
264 | let decorationY = y;
265 |
266 | if (store.isUnderline) {
267 | decorationY = y + textMetrics.actualBoundingBoxDescent + 2;
268 | }
269 | if (store.isStrikethrough) {
270 | decorationY = y;
271 | }
272 |
273 | let startX = x;
274 | if (store.textAlign === "center") {
275 | startX = x - textWidth / 2;
276 | } else if (store.textAlign === "right") {
277 | startX = x - textWidth;
278 | }
279 |
280 | ctx.beginPath();
281 | ctx.strokeStyle = store.textColor;
282 | ctx.lineWidth = Math.max(1, store.fontSize * 0.05);
283 | ctx.moveTo(startX, decorationY);
284 | ctx.lineTo(startX + textWidth, decorationY);
285 | ctx.stroke();
286 | }
287 | });
288 |
289 | ctx.restore();
290 | }
291 |
292 | if (store.sizeMode === "image" && store.logoImage) {
293 | const img = new Image();
294 | await new Promise((resolve, reject) => {
295 | img.onload = resolve;
296 | img.onerror = reject;
297 | img.src = store.logoImage as string;
298 | });
299 |
300 | const maxWidth = (store.resolution.width * store.fontSize) / 100; // Convert percentage to pixels
301 | const maxHeight = (store.resolution.height * store.fontSize) / 100;
302 | const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
303 | const width = img.width * scale;
304 | const height = img.height * scale;
305 |
306 | ctx.save();
307 | ctx.globalAlpha = store.opacity / 100;
308 | ctx.filter = `drop-shadow(${store.textShadow.offsetX}px ${store.textShadow.offsetY}px ${store.textShadow.blur}px ${store.textShadow.color})`;
309 | ctx.drawImage(
310 | img,
311 | store.resolution.width / 2 - width / 2 + store.textPosition.x,
312 | store.resolution.height / 2 - height / 2 + store.textPosition.y,
313 | width,
314 | height
315 | );
316 | ctx.restore();
317 | }
318 |
319 | // Convert to blob and copy
320 | try {
321 | // Try modern Clipboard API first
322 | const blob = await new Promise((resolve) =>
323 | tempCanvas.toBlob((blob) => resolve(blob!), "image/png")
324 | );
325 | await navigator.clipboard.write([
326 | new ClipboardItem({
327 | "image/png": blob,
328 | }),
329 | ]);
330 | } catch (e) {
331 | console.error(e);
332 | // Fallback for Safari
333 | const dataUrl = tempCanvas.toDataURL("image/png");
334 | const textArea = document.createElement("textarea");
335 | textArea.value = dataUrl;
336 | document.body.appendChild(textArea);
337 | textArea.select();
338 | try {
339 | document.execCommand("copy");
340 | document.body.removeChild(textArea);
341 | } catch (err) {
342 | document.body.removeChild(textArea);
343 | console.error(err);
344 | throw new Error("Failed to copy to clipboard");
345 | }
346 | }
347 |
348 | toast.success("Image copied to clipboard");
349 | } catch (err) {
350 | console.error(err);
351 | toast.error("Failed to copy image");
352 | }
353 | };
354 |
355 | const handleColorChange = (color: string) => {
356 | switch (store.activeColorType) {
357 | case "text":
358 | store.setTextColor(color);
359 | break;
360 | case "background":
361 | store.setBackgroundColor(color);
362 | break;
363 | case "gradient":
364 | if (store.activeColor !== null) {
365 | store.updateColor(color, store.activeColor);
366 | }
367 | break;
368 | }
369 | };
370 |
371 | const handleImageUpload = (e: React.ChangeEvent) => {
372 | const file = e.target.files?.[0];
373 | if (!file) return;
374 |
375 | // Check file size (e.g., 10MB limit)
376 | if (file.size > 10 * 1024 * 1024) {
377 | toast.error("Image must be smaller than 10MB");
378 | return;
379 | }
380 |
381 | // Check file type
382 | if (!file.type.startsWith("image/")) {
383 | toast.error("Please upload an image file");
384 | return;
385 | }
386 |
387 | const reader = new FileReader();
388 |
389 | reader.onerror = () => {
390 | toast.error("Failed to read image file");
391 | };
392 |
393 | reader.onloadend = () => {
394 | const loadPromise = new Promise((resolve, reject) => {
395 | const img = new Image();
396 | img.onload = () => {
397 | store.backgroundImage = reader.result as string;
398 | resolve(true);
399 | };
400 | img.onerror = reject;
401 | img.src = reader.result as string;
402 | });
403 |
404 | toast.promise(loadPromise, {
405 | loading: "Loading image...",
406 | success: "Image uploaded successfully",
407 | error: "Failed to load image",
408 | });
409 | };
410 |
411 | reader.readAsDataURL(file);
412 | };
413 |
414 | const handlePaletteChange = () => {
415 | const generateHarmonious = () => {
416 | // Color schemes with more variety
417 | const schemes = [
418 | { hueStep: 30, count: Math.floor(Math.random() * 6) + 3 }, // Analogous
419 | { hueStep: 120, count: Math.floor(Math.random() * 4) + 3 }, // Triadic
420 | { hueStep: 180, count: Math.floor(Math.random() * 4) + 2 }, // Split complementary
421 | { hueStep: 60, count: Math.floor(Math.random() * 6) + 3 }, // Hexadic
422 | { hueStep: 90, count: Math.floor(Math.random() * 4) + 3 }, // Square
423 | { hueStep: 45, count: Math.floor(Math.random() * 5) + 3 }, // Custom angle
424 | ];
425 |
426 | const scheme = schemes[Math.floor(Math.random() * schemes.length)];
427 | const baseHue = Math.random() * 360;
428 |
429 | // Enhanced saturation and lightness ranges
430 | const satRanges = [
431 | { min: 70, max: 90 }, // Vibrant
432 | { min: 40, max: 60 }, // Muted
433 | { min: 85, max: 100 }, // Super saturated
434 | { min: 55, max: 75 }, // Balanced
435 | ];
436 |
437 | const lightRanges = [
438 | { min: 40, max: 60 }, // Medium
439 | { min: 60, max: 80 }, // Light
440 | { min: 20, max: 40 }, // Dark
441 | { min: 30, max: 70 }, // Wide range
442 | ];
443 |
444 | // Generate background color with contrasting settings
445 | const bgHue = (baseHue + 180) % 360;
446 | const bgSat = 20 + Math.random() * 40;
447 | const bgLight =
448 | Math.random() > 0.5
449 | ? 10 + Math.random() * 20 // Dark background
450 | : 80 + Math.random() * 15; // Light background
451 |
452 | // Set background color
453 | const backgroundColor = hslToHex(bgHue, bgSat, bgLight);
454 | store.setBackgroundColor(backgroundColor);
455 |
456 | // Text color: pure white or black based on background, with slight variation
457 | const textLight =
458 | bgLight < 50
459 | ? 95 + Math.random() * 5 // Almost white for dark backgrounds (95-100%)
460 | : Math.random() * 5; // Almost black for light backgrounds (0-5%)
461 |
462 | const textColor = hslToHex(0, 0, textLight); // Hue and saturation 0 for pure grayscale
463 | store.setTextColor(textColor);
464 |
465 | // Glow: slightly less extreme than text for subtle effect
466 | const glowLight =
467 | bgLight < 50
468 | ? textLight - (10 + Math.random() * 15) // Slightly darker than white text
469 | : textLight + (10 + Math.random() * 15); // Slightly lighter than black text
470 |
471 | store.setTextShadow({
472 | color: hslToHex(0, 0, glowLight), // Pure grayscale glow
473 | blur: store.textShadow.blur, // Smaller blur range for subtlety
474 | offsetX: store.textShadow.offsetX,
475 | offsetY: store.textShadow.offsetY,
476 | });
477 |
478 | const satRange = satRanges[Math.floor(Math.random() * satRanges.length)];
479 | const lightRange =
480 | lightRanges[Math.floor(Math.random() * lightRanges.length)];
481 |
482 | // Generate base colors from the scheme
483 | const baseColors = Array.from({ length: scheme.count }, (_, i) => {
484 | const hue = (baseHue + i * scheme.hueStep) % 360;
485 | const sat =
486 | satRange.min + Math.random() * (satRange.max - satRange.min);
487 | const light =
488 | lightRange.min + Math.random() * (lightRange.max - lightRange.min);
489 | return { h: hue, s: sat, l: light };
490 | });
491 |
492 | // Add variations with more diverse modifications
493 | const colors = baseColors.flatMap((base) => {
494 | const variations = [base];
495 |
496 | // Random chance for additional variations
497 | if (Math.random() > 0.3) {
498 | variations.push({
499 | h: (base.h + 15 - Math.random() * 30) % 360, // Slight hue shift
500 | s: Math.max(20, Math.min(100, base.s + (Math.random() * 30 - 15))),
501 | l: Math.max(10, Math.min(90, base.l + (Math.random() * 40 - 20))),
502 | });
503 | }
504 | return variations;
505 | });
506 |
507 | // Convert HSL to Hex
508 | return colors.map(({ h, s, l }) => hslToHex(h, s, l));
509 | };
510 |
511 | // Helper function to convert HSL to Hex
512 | const hslToHex = (h: number, s: number, l: number) => {
513 | const hue = h / 360;
514 | const sat = s / 100;
515 | const light = l / 100;
516 |
517 | const c = (1 - Math.abs(2 * light - 1)) * sat;
518 | const x = c * (1 - Math.abs(((hue * 6) % 2) - 1));
519 | const m = light - c / 2;
520 |
521 | let r, g, b;
522 | if (hue < 1 / 6) [r, g, b] = [c, x, 0];
523 | else if (hue < 2 / 6) [r, g, b] = [x, c, 0];
524 | else if (hue < 3 / 6) [r, g, b] = [0, c, x];
525 | else if (hue < 4 / 6) [r, g, b] = [0, x, c];
526 | else if (hue < 5 / 6) [r, g, b] = [x, 0, c];
527 | else [r, g, b] = [c, 0, x];
528 |
529 | const toHex = (n: number) => {
530 | const hex = Math.round((n + m) * 255).toString(16);
531 | return hex.length === 1 ? "0" + hex : hex;
532 | };
533 |
534 | return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
535 | };
536 |
537 | const newColors = generateHarmonious();
538 | store.setCircles(
539 | newColors.map((color, i) => ({
540 | color,
541 | cx: store.circles[i]?.cx ?? Math.random() * 100,
542 | cy: store.circles[i]?.cy ?? Math.random() * 100,
543 | }))
544 | );
545 | };
546 |
547 | const AppComponent = isMobile ? MobileApp : DesktopApp;
548 |
549 | return (
550 |
560 | );
561 | }
562 |
--------------------------------------------------------------------------------
/app/pwa.ts:
--------------------------------------------------------------------------------
1 | export function registerServiceWorker() {
2 | if (typeof window !== "undefined" && "serviceWorker" in navigator) {
3 | window.addEventListener("load", () => {
4 | navigator.serviceWorker
5 | .register("/service-worker.js")
6 | .then((registration) => {
7 | console.log("SW registered:", registration);
8 | })
9 | .catch((error) => {
10 | console.log("SW registration failed:", error);
11 | });
12 | });
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/assets/index.tsx:
--------------------------------------------------------------------------------
1 | import tweet1 from "./twt-1.png";
2 | import tweet2 from "./twt-2.png";
3 | import tweet3 from "./twt-3.png";
4 | import tweet4 from "./twt-4.png";
5 | import x from "./x.png";
6 | import banner from "./gradii-banner.png";
7 | import logo from "./logo.svg";
8 | export const IMAGES = {
9 | tweet1: tweet1,
10 | tweet2: tweet2,
11 | tweet3: tweet3,
12 | tweet4: tweet4,
13 | x: x,
14 | banner: banner,
15 | logo: logo,
16 | };
17 |
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/components/core-ui/canvas-preview.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useCallback, useMemo } from "react";
2 |
3 | import { generateRandomShape } from "@/lib/utils/shapes";
4 | import "context-filter-polyfill";
5 | import { debounce } from "@/lib/utils";
6 | import { applyGrainEffect } from "@/lib/utils/effects";
7 | import { drawShape } from "@/lib/utils/shapes";
8 | import { useWallpaperStore } from "@/store/wallpaper";
9 |
10 | export function CanvasPreview() {
11 | const canvasRef = useRef(null);
12 | const offscreenCanvasRef = useRef(null);
13 | const backgroundLayerRef = useRef(null);
14 | const store = useWallpaperStore();
15 |
16 | const debouncedCompositeCanvas = useMemo(
17 | () => debounce((fn: () => void) => fn(), 16), // ~60fps
18 | []
19 | );
20 |
21 | const savedValues = useMemo(
22 | () => ({
23 | // Filters
24 | blur: store.blur,
25 | brightness: store.brightness,
26 | contrast: store.contrast,
27 | saturation: store.saturation,
28 |
29 | // Colors and Background
30 | backgroundColor: store.backgroundColor,
31 | backgroundImage: store.backgroundImage,
32 | circles: store.circles,
33 |
34 | // Text Properties
35 | text: store.text,
36 | fontSize: store.fontSize,
37 | fontWeight: store.fontWeight,
38 | letterSpacing: store.letterSpacing,
39 | opacity: store.opacity,
40 | fontFamily: store.fontFamily,
41 | lineHeight: store.lineHeight,
42 | textColor: store.textColor,
43 | isItalic: store.isItalic,
44 | isUnderline: store.isUnderline,
45 | isStrikethrough: store.isStrikethrough,
46 |
47 | // Effects
48 | grainIntensity: store.grainIntensity,
49 | textShadow: store.textShadow,
50 |
51 | // Position and Mode
52 | textPosition: store.textPosition,
53 | sizeMode: store.sizeMode,
54 | logoImage: store.logoImage,
55 |
56 | // Resolution
57 | resolution: store.resolution,
58 |
59 | // Text Alignment
60 | textAlign: store.textAlign,
61 | }),
62 | [
63 | store.blur,
64 | store.brightness,
65 | store.contrast,
66 | store.saturation,
67 | store.backgroundColor,
68 | store.backgroundImage,
69 | store.circles,
70 | store.text,
71 | store.fontSize,
72 | store.fontWeight,
73 | store.letterSpacing,
74 | store.opacity,
75 | store.fontFamily,
76 | store.lineHeight,
77 | store.textColor,
78 | store.isItalic,
79 | store.isUnderline,
80 | store.isStrikethrough,
81 | store.grainIntensity,
82 | store.textShadow,
83 | store.textPosition,
84 | store.sizeMode,
85 | store.logoImage,
86 | store.resolution,
87 | store.textAlign,
88 | ]
89 | );
90 |
91 | const effectiveValues = savedValues;
92 |
93 | // Initialize canvases once
94 | useEffect(() => {
95 | if (!offscreenCanvasRef.current) {
96 | offscreenCanvasRef.current = document.createElement("canvas");
97 | backgroundLayerRef.current = document.createElement("canvas");
98 | }
99 |
100 | [offscreenCanvasRef, backgroundLayerRef].forEach((ref) => {
101 | if (ref.current) {
102 | ref.current.width = effectiveValues.resolution.width;
103 | ref.current.height = effectiveValues.resolution.height;
104 | }
105 | });
106 | }, [effectiveValues.resolution.width, effectiveValues.resolution.height]);
107 |
108 | // Handle background and shapes
109 | useEffect(() => {
110 | if (!backgroundLayerRef.current) return;
111 | const ctx = backgroundLayerRef.current.getContext("2d")!;
112 |
113 | ctx.clearRect(
114 | 0,
115 | 0,
116 | effectiveValues.resolution.width,
117 | effectiveValues.resolution.height
118 | );
119 | ctx.fillStyle = effectiveValues.backgroundColor;
120 | ctx.fillRect(
121 | 0,
122 | 0,
123 | effectiveValues.resolution.width,
124 | effectiveValues.resolution.height
125 | );
126 |
127 | if (effectiveValues.backgroundImage) {
128 | const img = new Image();
129 | img.src = effectiveValues.backgroundImage;
130 | img.onload = () => {
131 | const scale = Math.max(
132 | effectiveValues.resolution.width / img.width,
133 | effectiveValues.resolution.height / img.height
134 | );
135 | const scaledWidth = img.width * scale;
136 | const scaledHeight = img.height * scale;
137 | const x = (effectiveValues.resolution.width - scaledWidth) / 2;
138 | const y = (effectiveValues.resolution.height - scaledHeight) / 2;
139 |
140 | ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
141 | debouncedCompositeCanvas(compositeCanvas);
142 | };
143 | } else {
144 | effectiveValues.circles.forEach((circle) => {
145 | const shape = generateRandomShape(circle.color);
146 | drawShape(ctx, shape, circle);
147 | });
148 | debouncedCompositeCanvas(compositeCanvas);
149 | }
150 | }, [
151 | effectiveValues.backgroundColor,
152 | effectiveValues.circles,
153 | effectiveValues.backgroundImage,
154 | effectiveValues.resolution.width,
155 | effectiveValues.resolution.height,
156 | ]);
157 |
158 | // Handle filters
159 | useEffect(() => {
160 | debouncedCompositeCanvas(compositeCanvas);
161 | }, [
162 | effectiveValues.blur,
163 | effectiveValues.brightness,
164 | effectiveValues.contrast,
165 | effectiveValues.saturation,
166 | ]);
167 |
168 | const compositeCanvas = useCallback(() => {
169 | if (!canvasRef.current || !backgroundLayerRef.current) return;
170 |
171 | const ctx = canvasRef.current.getContext("2d", {
172 | alpha: true,
173 | willReadFrequently: false,
174 | })!;
175 |
176 | // Clear main canvas
177 | ctx.clearRect(
178 | 0,
179 | 0,
180 | effectiveValues.resolution.width,
181 | effectiveValues.resolution.height
182 | );
183 |
184 | // 1. Draw solid background color first (no filters)
185 | ctx.fillStyle = effectiveValues.backgroundColor;
186 | ctx.fillRect(
187 | 0,
188 | 0,
189 | effectiveValues.resolution.width,
190 | effectiveValues.resolution.height
191 | );
192 |
193 | // 2. Draw shapes/gradients with filters
194 | const cssFilters = [
195 | effectiveValues.blur > 0 ? `blur(${effectiveValues.blur / 4}px)` : "",
196 | `brightness(${effectiveValues.brightness}%)`,
197 | `contrast(${effectiveValues.contrast}%)`,
198 | `saturate(${effectiveValues.saturation}%)`,
199 | ]
200 | .filter(Boolean)
201 | .join(" ");
202 |
203 | ctx.filter = cssFilters;
204 | ctx.drawImage(backgroundLayerRef.current, 0, 0);
205 |
206 | // 3. Apply film grain
207 | if (effectiveValues.grainIntensity > 0) {
208 | applyGrainEffect(ctx, effectiveValues.grainIntensity / 100);
209 | }
210 | }, [
211 | effectiveValues.blur,
212 | effectiveValues.brightness,
213 | effectiveValues.contrast,
214 | effectiveValues.saturation,
215 | effectiveValues.resolution.width,
216 | effectiveValues.resolution.height,
217 | effectiveValues.backgroundColor,
218 | effectiveValues.grainIntensity,
219 | ]);
220 |
221 | return (
222 |
239 | );
240 | }
241 |
--------------------------------------------------------------------------------
/components/core-ui/footer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import AnimatedGradient from "@/components/ui/animatedGradient";
4 | import Link from "next/link";
5 | import { motion, useScroll, useTransform } from "motion/react";
6 |
7 | export default function Footer() {
8 | const { scrollYProgress } = useScroll();
9 |
10 | return (
11 |
12 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | cool asf blogs
40 |
41 | {" "}
42 |
43 |
44 | cool asf social media
45 |
46 |
47 |
48 |
49 | cool asf yt clone
50 |
51 |
52 |
53 |
54 | cool asf summarizer
55 |
56 |
57 |
58 |
59 |
60 |
61 | X (Twitter)
62 |
63 |
64 |
65 |
66 | Github
67 |
68 |
69 |
70 |
74 | Linkedin
75 |
76 |
77 |
78 |
79 |
84 | KSHV.
85 |
86 |
87 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/components/core-ui/wallpaper-preview.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { CircleProps } from "@/lib/constants";
3 |
4 | interface WallpaperPreviewProps {
5 | width: number;
6 | height: number;
7 | backgroundColor: string;
8 | circles: CircleProps[];
9 | text: string;
10 | textStyle: {
11 | fontSize: number;
12 | fontWeight: number;
13 | letterSpacing: number;
14 | fontFamily: string;
15 | opacity: number;
16 | lineHeight: number;
17 | color: string;
18 | isItalic: boolean;
19 | isUnderline: boolean;
20 | isStrikethrough: boolean;
21 | textShadow: {
22 | color: string;
23 | blur: number;
24 | offsetX: number;
25 | offsetY: number;
26 | };
27 | };
28 | filters: {
29 | blur: number;
30 | brightness: number;
31 | contrast: number;
32 | saturation: number;
33 | };
34 | effects: {
35 | grain: number;
36 | vignette: number;
37 | };
38 | backgroundImage: string | null;
39 | }
40 |
41 | export function WallpaperPreview({
42 | width,
43 | height,
44 | backgroundColor,
45 | circles,
46 | text,
47 | textStyle,
48 | filters,
49 | effects,
50 | backgroundImage,
51 | }: WallpaperPreviewProps) {
52 | return (
53 |
61 | {/* Base Layer */}
62 |
75 |
76 | {/* Background Image */}
77 | {backgroundImage && (
78 |
91 | )}
92 |
93 | {/* Shapes Layer */}
94 |
95 | {circles.map((circle, i) => (
96 |
113 | ))}
114 |
115 |
116 | {/* Text Layer */}
117 |
138 | {text}
139 |
140 |
141 | {/* Effects Layer */}
142 | {effects.grain > 0 && (
143 |
152 | )}
153 |
154 | {/* Vignette Effect */}
155 | {effects.vignette > 0 && (
156 |
167 | )}
168 |
169 | );
170 | }
171 |
--------------------------------------------------------------------------------
/components/drawer-content/settings-drawer-content.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "../ui/button";
2 | import { ChevronLeftIcon } from "lucide-react";
3 | import Image from "next/image";
4 | import { ThemeSwitch } from "../ui/themeSwitch";
5 | import logo from "@/public/logo.svg";
6 | import GithubIcon from "@/lib/icons/github";
7 | import Link from "next/link";
8 | import { motion } from "motion/react";
9 | import TwitterIcon from "@/lib/icons/twitter";
10 | export default function SettingsDrawerContent({
11 | setIsSettingsOpen,
12 | }: {
13 | setIsSettingsOpen: (isSettingsOpen: boolean) => void;
14 | }) {
15 | return (
16 |
17 |
18 |
22 |
23 |
24 | Give us a star
25 |
26 |
27 | setIsSettingsOpen(false)}
31 | className="rounded-xl"
32 | >
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
52 |
59 |
60 |
61 |
62 | Welcome to Gradii
63 |
64 | BETA
65 |
66 |
67 |
68 |
69 | Gradii is a simple gradient generator tool made by designer for
70 | designers to create stunning gradients with customizable colors,
71 | text, and effects.
72 |
73 |
74 |
75 |
80 |
81 |
82 | Enjoying Gradii ? Share your
83 | experience on X/Twitter
84 |
85 |
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 |
6 | export function ThemeProvider({
7 | children,
8 | ...props
9 | }: React.ComponentProps) {
10 | return {children} ;
11 | }
12 |
--------------------------------------------------------------------------------
/components/ui/animatedGradient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useMemo, useRef } from "react";
3 | import { cn } from "@/lib/utils";
4 | import { useDimensions } from "@/hooks/use-debounced-dimensions";
5 |
6 | interface AnimatedGradientProps {
7 | colors: string[];
8 | speed?: number;
9 | blur?: "light" | "medium" | "heavy";
10 | }
11 |
12 | const randomInt = (min: number, max: number) => {
13 | return Math.floor(Math.random() * (max - min + 1)) + min;
14 | };
15 |
16 | const AnimatedGradient: React.FC = ({
17 | colors,
18 | speed = 5,
19 | blur = "light",
20 | }) => {
21 | const containerRef = useRef(
22 | null
23 | ) as React.RefObject;
24 | const dimensions = useDimensions(containerRef);
25 |
26 | const circleSize = useMemo(
27 | () => Math.max(dimensions.width, dimensions.height),
28 | [dimensions.width, dimensions.height]
29 | );
30 |
31 | const blurClass =
32 | blur === "light"
33 | ? "blur-2xl"
34 | : blur === "medium"
35 | ? "blur-3xl"
36 | : "blur-[100px]";
37 |
38 | return (
39 |
40 |
41 | {colors.map((color, index) => (
42 |
64 |
65 |
66 | ))}
67 |
68 |
69 | );
70 | };
71 |
72 | export default AnimatedGradient;
73 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm relative cursor-pointer transition-all duration-300 rounded-xl border border-primary/10 bg-foreground/5 hover:bg-primary/10 backdrop-blur-md w-full disabled:opacity-50 disabled:cursor-not-allowed hover:border-primary/20 transition-all duration-300 ease-[cubic-bezier(0.45, 0.05, 0.55, 0.95)]",
9 | {
10 | variants: {
11 | variant: {
12 | default: "text-foreground",
13 | accent:
14 | "bg-secondary-foreground hover:bg-secondary-foreground/80 text-secondary",
15 | destructive:
16 | "bg-destructive/50 text-destructive-foreground hover:bg-destructive/90",
17 | outline:
18 | "border border-input hover:bg-accent hover:text-accent-foreground",
19 | secondary:
20 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
21 | ghost:
22 | "hover:bg-foreground/10 hover:text-accent-foreground border-none bg-transparent",
23 | link: "text-primary underline-offset-4 hover:underline",
24 | success: "bg-green-600/50 text-white hover:bg-green-600/90",
25 | },
26 | size: {
27 | default: "px-4 py-2",
28 | sm: "px-3 py-1.5",
29 | lg: "px-3 py-4",
30 | icon: "h-10 w-10",
31 | },
32 | },
33 | defaultVariants: {
34 | variant: "default",
35 | size: "default",
36 | },
37 | }
38 | );
39 |
40 | export interface ButtonProps
41 | extends React.ButtonHTMLAttributes,
42 | VariantProps {
43 | asChild?: boolean;
44 | }
45 |
46 | const Button = React.forwardRef(
47 | ({ className, variant, size, asChild = false, ...props }, ref) => {
48 | const Comp = asChild ? Slot : "button";
49 | return (
50 |
55 | );
56 | }
57 | );
58 | Button.displayName = "Button";
59 |
60 | export { Button, buttonVariants };
61 |
--------------------------------------------------------------------------------
/components/ui/buttonsChin.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { WandSparklesIcon } from "lucide-react";
5 | import { motion } from "motion/react";
6 | import { Button } from "./button";
7 |
8 | interface ButtonsChinProps {
9 | isMobile?: boolean;
10 | generateNewPalette: () => void;
11 | isGenerating: boolean;
12 | setBackgroundImage: (image: string | null) => void;
13 | setBlur: (blur: number) => void;
14 | blur: number;
15 | }
16 |
17 | export function ButtonsChin({
18 | isMobile = false,
19 | generateNewPalette,
20 | isGenerating,
21 | setBackgroundImage,
22 | setBlur,
23 | blur,
24 | }: ButtonsChinProps) {
25 | if (isMobile) {
26 | return (
27 |
28 | {
31 | generateNewPalette();
32 | setBackgroundImage(null);
33 | if (blur === 0) {
34 | setBlur(600);
35 | }
36 | }}
37 | disabled={isGenerating}
38 | >
39 |
40 | Generate
41 |
42 |
43 | );
44 | }
45 |
46 | return (
47 |
61 | {
65 | generateNewPalette();
66 | setBackgroundImage(null);
67 | if (blur === 0) {
68 | setBlur(600);
69 | }
70 | }}
71 | disabled={isGenerating}
72 | >
73 |
74 | Generate
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | // import { X } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ));
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 | {/*
48 |
49 | Close
50 | */}
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName;
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | );
68 | DialogHeader.displayName = "DialogHeader";
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | );
82 | DialogFooter.displayName = "DialogFooter";
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ));
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | };
123 |
--------------------------------------------------------------------------------
/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { Drawer } from "vaul";
5 |
6 | export default function VaulDrawer({
7 | children,
8 | title,
9 | direction = "left",
10 | className,
11 | isOpen,
12 | setIsOpen,
13 | showHandle = false,
14 | }: {
15 | children: React.ReactNode;
16 | title: string;
17 | direction?: "left" | "right" | "top" | "bottom";
18 | className?: string;
19 | showHandle?: boolean;
20 | isOpen: boolean;
21 | setIsOpen: (isOpen: boolean) => void;
22 | }) {
23 | return (
24 |
30 |
31 |
32 |
38 | {showHandle && (
39 |
40 |
41 |
42 | )}
43 | {title}
44 | {children}
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import { Check, ChevronRight, Circle } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName;
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ));
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName;
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ));
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean;
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ));
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ));
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName;
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ));
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean;
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ));
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ));
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | );
181 | };
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | };
201 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | );
18 | }
19 | );
20 | Input.displayName = "Input";
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/marquee.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { ComponentPropsWithoutRef } from "react";
3 |
4 | interface MarqueeProps extends ComponentPropsWithoutRef<"div"> {
5 | /**
6 | * Optional CSS class name to apply custom styles
7 | */
8 | className?: string;
9 | /**
10 | * Whether to reverse the animation direction
11 | * @default false
12 | */
13 | reverse?: boolean;
14 | /**
15 | * Whether to pause the animation on hover
16 | * @default false
17 | */
18 | pauseOnHover?: boolean;
19 | /**
20 | * Content to be displayed in the marquee
21 | */
22 | children: React.ReactNode;
23 | /**
24 | * Whether to animate vertically instead of horizontally
25 | * @default false
26 | */
27 | vertical?: boolean;
28 | /**
29 | * Number of times to repeat the content
30 | * @default 4
31 | */
32 | repeat?: number;
33 | }
34 |
35 | export default function Marquee({
36 | className,
37 | reverse = false,
38 | pauseOnHover = false,
39 | children,
40 | vertical = false,
41 | repeat = 4,
42 | ...props
43 | }: MarqueeProps) {
44 | return (
45 |
56 | {Array(repeat)
57 | .fill(0)
58 | .map((_, i) => (
59 |
68 | {children}
69 |
70 | ))}
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as PopoverPrimitive from "@radix-ui/react-popover";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Popover = PopoverPrimitive.Root;
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger;
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ));
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30 |
31 | export { Popover, PopoverTrigger, PopoverContent };
32 |
--------------------------------------------------------------------------------
/components/ui/position-control.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useState } from "react";
2 | import { cn } from "@/lib/utils";
3 | import { Switch } from "./switch";
4 |
5 | interface Position {
6 | x: number;
7 | y: number;
8 | }
9 |
10 | interface PositionControlProps {
11 | value: Position;
12 | onChange: (position: Position) => void;
13 | width: number;
14 | height: number;
15 | className?: string;
16 | }
17 |
18 | export function PositionControl({
19 | value,
20 | onChange,
21 | width,
22 | height,
23 | className,
24 | }: PositionControlProps) {
25 | const containerRef = useRef(null);
26 | const [isDragging, setIsDragging] = useState(false);
27 | const [snapToGrid, setSnapToGrid] = useState(true);
28 | const GRID_SIZE = 20; // matches our background grid size
29 |
30 | // Calculate aspect ratio and dimensions
31 | const aspectRatio = width / height;
32 | const isWide = aspectRatio > 1;
33 |
34 | const containerStyle = isWide
35 | ? {
36 | width: "100%",
37 | height: `${(1 / aspectRatio) * 100}%`,
38 | aspectRatio: aspectRatio,
39 | }
40 | : {
41 | width: `${aspectRatio * 100}%`,
42 | height: "100%",
43 | aspectRatio: aspectRatio,
44 | };
45 |
46 | // Convert absolute coordinates to relative (-1 to 1)
47 | const absoluteToRelative = (x: number, y: number): Position => {
48 | const rect = containerRef.current?.getBoundingClientRect();
49 | if (!rect) return { x: 0, y: 0 };
50 |
51 | const relX = ((x - rect.left) / rect.width) * 2 - 1;
52 | const relY = ((y - rect.top) / rect.height) * 2 - 1;
53 |
54 | let newX = Math.max(-1, Math.min(1, relX)) * (width / 2);
55 | let newY = Math.max(-1, Math.min(1, relY)) * (height / 2);
56 |
57 | if (snapToGrid) {
58 | // Convert to grid space
59 | const gridStepX = width / 2 / (rect.width / GRID_SIZE);
60 | const gridStepY = height / 2 / (rect.height / GRID_SIZE);
61 |
62 | // Snap to nearest grid point
63 | newX = Math.round(newX / gridStepX) * gridStepX;
64 | newY = Math.round(newY / gridStepY) * gridStepY;
65 | }
66 |
67 | return { x: newX, y: newY };
68 | };
69 |
70 | // Handle mouse/touch events
71 | const handlePointerMove = (e: PointerEvent) => {
72 | if (!isDragging) return;
73 | e.preventDefault();
74 | const pos = absoluteToRelative(e.clientX, e.clientY);
75 | onChange(pos);
76 | };
77 |
78 | const handlePointerUp = () => {
79 | setIsDragging(false);
80 | };
81 |
82 | useEffect(() => {
83 | if (isDragging) {
84 | window.addEventListener("pointermove", handlePointerMove);
85 | window.addEventListener("pointerup", handlePointerUp);
86 | }
87 | return () => {
88 | window.removeEventListener("pointermove", handlePointerMove);
89 | window.removeEventListener("pointerup", handlePointerUp);
90 | };
91 | }, [isDragging, snapToGrid]);
92 |
93 | // Convert relative position back to pixel coordinates for the handle
94 | const handlePosition = {
95 | left: `${((value.x / (width / 2) + 1) / 2) * 100}%`,
96 | top: `${((value.y / (height / 2) + 1) / 2) * 100}%`,
97 | };
98 |
99 | return (
100 |
105 |
106 |
Position
107 |
108 |
112 | Snap to Grid
113 |
114 |
119 |
120 |
121 |
{
128 | setIsDragging(true);
129 | const pos = absoluteToRelative(e.clientX, e.clientY);
130 | onChange(pos);
131 | }}
132 | style={{
133 | ...containerStyle,
134 | backgroundImage:
135 | "radial-gradient(circle at center, hsl(var(--foreground) / 0.1) 1px, transparent 1px)",
136 | backgroundSize: `${GRID_SIZE}px ${GRID_SIZE}px`,
137 | }}
138 | >
139 |
143 |
144 |
145 | );
146 | }
147 |
--------------------------------------------------------------------------------
/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SelectPrimitive from "@radix-ui/react-select";
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Select = SelectPrimitive.Root;
10 |
11 | const SelectGroup = SelectPrimitive.Group;
12 |
13 | const SelectValue = SelectPrimitive.Value;
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1 border border-primary/10 hover:border-primary/20 transition-all duration-300 ease-[cubic-bezier(0.45, 0.05, 0.55, 0.95)]",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ));
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ));
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ));
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName;
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ));
100 | SelectContent.displayName = SelectPrimitive.Content.displayName;
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ));
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ));
135 | SelectItem.displayName = SelectPrimitive.Item.displayName;
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ));
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | };
161 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | );
29 | Separator.displayName = SeparatorPrimitive.Root.displayName;
30 |
31 | export { Separator };
32 |
--------------------------------------------------------------------------------
/components/ui/sidebarHeader.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dialog,
3 | DialogContent,
4 | DialogTrigger,
5 | DialogTitle,
6 | } from "@/components/ui/dialog";
7 | import {
8 | BrushIcon,
9 | DownloadIcon,
10 | InfoIcon,
11 | PaletteIcon,
12 | WandSparklesIcon,
13 | } from "lucide-react";
14 | import Link from "next/link";
15 | import logo from "@/public/logo.svg";
16 | import Image from "next/image";
17 | import gradientWallpaper from "@/public/gradii-logo.png";
18 | import { motion } from "motion/react";
19 | import { IMAGES } from "@/assets";
20 | import Marquee from "./marquee";
21 | import { useEffect, useState } from "react";
22 |
23 | const CURRENT_VERSION = "0.3";
24 |
25 | export function SidebarHeader() {
26 | const [open, setOpen] = useState(false);
27 |
28 | useEffect(() => {
29 | // Clean up ALL old version keys
30 | const cleanupOldVersions = () => {
31 | const oldVersions = ["0.1", "0.2", "0.3"];
32 | oldVersions.forEach((version) => {
33 | localStorage.removeItem(`gradiiLastSeenVersion_${version}`);
34 | });
35 | localStorage.removeItem("hasSeenGradiiDialog"); // Remove the very old key too
36 | };
37 |
38 | cleanupOldVersions();
39 |
40 | const lastSeenVersion = localStorage.getItem("gradiiLastSeenVersion");
41 | if (!lastSeenVersion || lastSeenVersion !== CURRENT_VERSION) {
42 | setOpen(true);
43 | localStorage.setItem("gradiiLastSeenVersion", CURRENT_VERSION);
44 | }
45 | }, []);
46 |
47 | return (
48 |
49 |
50 |
51 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | Gradii
70 |
83 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | Elegant Gradients
98 |
99 |
100 | Create gradients of infinite possibilities with up to 8
101 | custom colors and background images.
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | Customizable text
110 |
111 |
112 | Add customizable text with various fonts and styles.
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | Cool Filters.
121 |
122 |
123 | Fine tune your wallpapers to your liking with noise, grain,
124 | static effects and other filters.
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | 4K Wallpapers.
133 |
134 |
135 | Download your custom wallpapers in up to 4k resolutions in
136 | desktop, mobile and square aspect ratios.
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | {[IMAGES.tweet1, IMAGES.tweet2, IMAGES.tweet3, IMAGES.tweet4].map(
146 | (image, i) => (
147 |
148 |
153 |
154 | )
155 | )}
156 |
157 |
158 |
159 |
160 |
161 |
162 | What's New in v0.4 ALPHA 👀 ✨
163 |
164 |
165 |
166 | Upgraded to Tailwind v4
167 | Better UX
168 | Cleaner codebase
169 |
170 |
171 |
172 |
173 | Found a bug or have feedback? Feel free to{" "}
174 |
179 | DM me on X
180 |
181 | .
182 |
183 |
184 |
185 |
186 | );
187 | }
188 |
--------------------------------------------------------------------------------
/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SliderPrimitive from "@radix-ui/react-slider";
5 | import { cn } from "@/lib/utils";
6 |
7 | const Slider = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef & {
10 | label: string;
11 | valueSubtext?: string;
12 | }
13 | >(({ className, label, valueSubtext, ...props }, ref) => (
14 |
15 |
16 | {label}
17 |
18 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | {valueSubtext ? `${props.value}${valueSubtext}` : props.value}
33 |
34 |
35 | ));
36 | Slider.displayName = SliderPrimitive.Root.displayName;
37 |
38 | export { Slider };
39 |
--------------------------------------------------------------------------------
/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 | import { Toaster as Sonner } from "sonner";
5 |
6 | type ToasterProps = React.ComponentProps;
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme();
10 |
11 | return (
12 |
28 | );
29 | };
30 |
31 | export { Toaster };
32 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SwitchPrimitives from "@radix-ui/react-switch";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ));
27 | Switch.displayName = SwitchPrimitives.Root.displayName;
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as TabsPrimitive from "@radix-ui/react-tabs";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Tabs = TabsPrimitive.Root;
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | TabsList.displayName = TabsPrimitive.List.displayName;
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ));
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ));
53 | TabsContent.displayName = TabsPrimitive.Content.displayName;
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent };
56 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | });
20 | Textarea.displayName = "Textarea";
21 |
22 | export { Textarea };
23 |
--------------------------------------------------------------------------------
/components/ui/themeSwitch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | import { Monitor, Moon, Sun } from "lucide-react";
6 | import { motion } from "motion/react";
7 | import { useTheme } from "next-themes";
8 | import { useEffect, useState } from "react";
9 |
10 | const themes = [
11 | {
12 | key: "system",
13 | icon: Monitor,
14 | label: "System theme",
15 | },
16 | {
17 | key: "light",
18 | icon: Sun,
19 | label: "Light theme",
20 | },
21 | {
22 | key: "dark",
23 | icon: Moon,
24 | label: "Dark theme",
25 | },
26 | ];
27 |
28 | export type ThemeSwitcherProps = {
29 | className?: string;
30 | };
31 |
32 | export const ThemeSwitch = ({ className }: ThemeSwitcherProps) => {
33 | const { theme, setTheme } = useTheme();
34 | const [mounted, setMounted] = useState(false);
35 |
36 | // Prevent hydration mismatch
37 | useEffect(() => {
38 | setMounted(true);
39 | }, []);
40 |
41 | if (!mounted) {
42 | return null;
43 | }
44 |
45 | const handleChangeTheme = (newTheme: "light" | "dark" | "system") => {
46 | if (newTheme === theme) return;
47 | if (!document.startViewTransition) return setTheme(newTheme);
48 | document.startViewTransition(() => setTheme(newTheme));
49 | };
50 |
51 | return (
52 |
58 | {themes.map(({ key, icon: Icon, label }) => {
59 | const isActive = theme === key;
60 |
61 | return (
62 |
67 | handleChangeTheme(key as "light" | "dark" | "system")
68 | }
69 | aria-label={label}
70 | >
71 | {isActive && (
72 |
77 | )}
78 |
86 |
87 | );
88 | })}
89 |
90 | );
91 | };
92 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/hooks/use-debounced-dimensions.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, RefObject } from "react";
2 |
3 | interface Dimensions {
4 | width: number;
5 | height: number;
6 | }
7 |
8 | export function useDimensions(
9 | ref: RefObject
10 | ): Dimensions {
11 | const [dimensions, setDimensions] = useState({
12 | width: 0,
13 | height: 0,
14 | });
15 |
16 | useEffect(() => {
17 | let timeoutId: NodeJS.Timeout;
18 |
19 | const updateDimensions = () => {
20 | if (ref.current) {
21 | const { width, height } = ref.current.getBoundingClientRect();
22 | setDimensions({ width, height });
23 | }
24 | };
25 |
26 | const debouncedUpdateDimensions = () => {
27 | clearTimeout(timeoutId);
28 | timeoutId = setTimeout(updateDimensions, 250); // Wait 250ms after resize ends
29 | };
30 |
31 | // Initial measurement
32 | updateDimensions();
33 |
34 | window.addEventListener("resize", debouncedUpdateDimensions);
35 |
36 | return () => {
37 | window.removeEventListener("resize", debouncedUpdateDimensions);
38 | clearTimeout(timeoutId);
39 | };
40 | }, [ref]);
41 |
42 | return dimensions;
43 | }
44 |
--------------------------------------------------------------------------------
/hooks/use-safari-check.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useSafariCheck() {
4 | const [isSafari, setIsSafari] = useState(false);
5 | const [shouldShowPWAPrompt, setShouldShowPWAPrompt] = useState(false);
6 |
7 | useEffect(() => {
8 | const userAgent = navigator.userAgent.toLowerCase();
9 | const isIosSafari =
10 | /iphone|ipad|ipod/.test(userAgent) &&
11 | /safari/.test(userAgent) &&
12 | !/crios/.test(userAgent);
13 | setIsSafari(/^((?!chrome|android).)*safari/i.test(userAgent));
14 |
15 | // Check if user has already dismissed the prompt
16 | const hasUserDismissedPrompt = localStorage.getItem("pwa-prompt-dismissed");
17 |
18 | // Only show prompt for iOS Safari and if not already dismissed
19 | if (
20 | isIosSafari &&
21 | !hasUserDismissedPrompt &&
22 | !window.matchMedia("(display-mode: standalone)").matches
23 | ) {
24 | setShouldShowPWAPrompt(true);
25 | }
26 | }, []);
27 |
28 | const dismissPWAPrompt = () => {
29 | localStorage.setItem("pwa-prompt-dismissed", "true");
30 | setShouldShowPWAPrompt(false);
31 | };
32 |
33 | return { isSafari, shouldShowPWAPrompt, dismissPWAPrompt };
34 | }
35 |
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export interface CircleProps {
2 | color: string;
3 | cx: number;
4 | cy: number;
5 | r?: string;
6 | }
7 |
8 | export interface FontOption {
9 | name: string;
10 | variable: boolean;
11 | weights: number[];
12 | }
13 |
14 | export interface Position {
15 | name: string;
16 | class: string;
17 | }
18 |
19 | export const INITIAL_COLORS = [
20 | "#001220", // Dark Blue
21 | "#FF6600", // Dark Orange
22 | "#002B50", // Navy Blue
23 | "#FFB366", // Light Orange
24 | "#004080", // Medium Blue
25 | "#FF8000", // Orange
26 | "#0066CC", // Bright Blue
27 | "#000000", // Black
28 | "#66A3FF", // Light Blue
29 | ];
30 |
31 | export const INITIAL_BACKGROUND_COLORS = [
32 | "#0D1319", // Dark Blue
33 | "#0D151A", // Dark Navy Blue
34 | "#0D161C", // Dark Medium Blue
35 | "#0D171D", // Dark Bright Blue
36 | "#0D191F", // Dark Light Blue
37 | "#1A160D", // Dark Light Orange
38 | "#1A130D", // Dark Orange
39 | "#1A110D", // Dark Dark Orange
40 | "#0D0D0D", // Dark Black
41 | ];
42 |
43 | export const FONTS: FontOption[] = [
44 | // Sans-serif fonts
45 | {
46 | name: "Bricolage Grotesque",
47 | variable: true,
48 | weights: [200, 300, 400, 500, 600, 700, 800],
49 | },
50 | {
51 | name: "Geist",
52 | variable: true,
53 | weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
54 | },
55 | {
56 | name: "Inter",
57 | variable: true,
58 | weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
59 | },
60 | {
61 | name: "Manrope",
62 | variable: true,
63 | weights: [200, 300, 400, 500, 600, 700, 800],
64 | },
65 | {
66 | name: "Montserrat",
67 | variable: true,
68 | weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
69 | },
70 | {
71 | name: "Onest",
72 | variable: true,
73 | weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
74 | },
75 | {
76 | name: "Poppins",
77 | variable: false,
78 | weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
79 | },
80 | {
81 | name: "Space Grotesk",
82 | variable: true,
83 | weights: [300, 400, 500, 600, 700],
84 | },
85 |
86 | // Serif fonts
87 | {
88 | name: "DM Serif Display",
89 | variable: false,
90 | weights: [400],
91 | },
92 | {
93 | name: "Instrument Serif",
94 | variable: false,
95 | weights: [400],
96 | },
97 | {
98 | name: "Lora",
99 | variable: true,
100 | weights: [400, 500, 600, 700],
101 | },
102 | {
103 | name: "Ms Madi",
104 | variable: false,
105 | weights: [400],
106 | },
107 |
108 | // Monospace fonts
109 | {
110 | name: "Space Mono",
111 | variable: false,
112 | weights: [400, 700],
113 | },
114 | ];
115 |
116 | export interface ResolutionPreset {
117 | name: string;
118 | width: number;
119 | height: number;
120 | category: string;
121 | }
122 |
123 | export const RESOLUTION_PRESETS: ResolutionPreset[] = [
124 | {
125 | name: "16:9",
126 | width: 1920,
127 | height: 1080,
128 | category: "",
129 | },
130 | {
131 | name: "3:2",
132 | width: 1920,
133 | height: 1280,
134 | category: "",
135 | },
136 | {
137 | name: "4:3",
138 | width: 1920,
139 | height: 1440,
140 | category: "",
141 | },
142 | {
143 | name: "5:4",
144 | width: 1920,
145 | height: 1536,
146 | category: "",
147 | },
148 | {
149 | name: "1:1",
150 | width: 1920,
151 | height: 1920,
152 | category: "",
153 | },
154 | {
155 | name: "4:5",
156 | width: 1080,
157 | height: 1350,
158 | category: "",
159 | },
160 | {
161 | name: "3:4",
162 | width: 1080,
163 | height: 1440,
164 | category: "",
165 | },
166 | {
167 | name: "2:3",
168 | width: 1080,
169 | height: 1620,
170 | category: "",
171 | },
172 | {
173 | name: "9:16",
174 | width: 1080,
175 | height: 1920,
176 | category: "",
177 | },
178 |
179 | // Mobile Devices
180 | {
181 | name: "iPhone 15",
182 | width: 1179,
183 | height: 2556,
184 | category: "Mobile Devices",
185 | },
186 | {
187 | name: "iPhone 15 Pro",
188 | width: 1179,
189 | height: 2556,
190 | category: "Mobile Devices",
191 | },
192 | {
193 | name: "iPhone 15 Pro Max",
194 | width: 1290,
195 | height: 2796,
196 | category: "Mobile Devices",
197 | },
198 | {
199 | name: "Android (S)",
200 | width: 720,
201 | height: 1520,
202 | category: "Mobile Devices",
203 | },
204 | {
205 | name: "Android (M)",
206 | width: 1080,
207 | height: 2400,
208 | category: "Mobile Devices",
209 | },
210 | {
211 | name: "Android (L)",
212 | width: 1440,
213 | height: 3200,
214 | category: "Mobile Devices",
215 | },
216 |
217 | // Tablets
218 | { name: 'iPad Pro 12.9"', width: 2048, height: 2732, category: "Tablets" },
219 | { name: "iPad Air", width: 1668, height: 2388, category: "Tablets" },
220 | { name: "Samsung Tab S7", width: 2560, height: 1600, category: "Tablets" },
221 |
222 | // Desktop & Monitors
223 | {
224 | name: "2K (QHD)",
225 | width: 2560,
226 | height: 1440,
227 | category: "Desktop & Monitors",
228 | },
229 | {
230 | name: "Full HD",
231 | width: 1920,
232 | height: 1080,
233 | category: "Desktop & Monitors",
234 | },
235 | { name: "4K UHD", width: 3840, height: 2160, category: "Desktop & Monitors" },
236 |
237 | // Use:
238 | { name: "Open Graph", width: 1200, height: 630, category: "Metadata" },
239 |
240 | // Facebook
241 | { name: "Story/Reels", width: 1080, height: 1920, category: "Facebook" },
242 | { name: "Event Cover", width: 1920, height: 1005, category: "Facebook" },
243 |
244 | // Instagram
245 | { name: "Square Post", width: 1080, height: 1080, category: "Instagram" },
246 | { name: "Portrait Post", width: 1080, height: 1350, category: "Instagram" },
247 | { name: "Story/Reels", width: 1080, height: 1920, category: "Instagram" },
248 |
249 | // Twitter
250 | { name: "Post Image", width: 1600, height: 900, category: "Twitter" },
251 | { name: "Header", width: 1500, height: 500, category: "Twitter" },
252 |
253 | // LinkedIn
254 | { name: "Post", width: 1200, height: 627, category: "LinkedIn" },
255 | { name: "Banner", width: 1584, height: 396, category: "LinkedIn" },
256 | ];
257 |
258 | export const BLUR_OPTIONS = [
259 | { name: "None", value: 0 },
260 | { name: "Low", value: 600 },
261 | { name: "Medium", value: 900 },
262 | { name: "High", value: 1200 },
263 | ] as const;
264 |
265 | export const SAFARI_BLUR_OPTIONS = [
266 | { name: "None", value: 0 },
267 | { name: "Low", value: 400 },
268 | { name: "Medium", value: 600 },
269 | { name: "High", value: 800 },
270 | ] as const;
271 |
272 | export interface AppProps {
273 | backgroundColor: string;
274 | fontSize: number;
275 | fontWeight: number;
276 | letterSpacing: number;
277 | fontFamily: string;
278 | opacity: number;
279 | lineHeight: number;
280 | text: string;
281 | circles: CircleProps[];
282 | textColor: string;
283 | generateNewPalette: () => void;
284 | downloadImage: () => void;
285 | isDownloading: boolean;
286 | previousCircles: CircleProps[];
287 | setCircles: (circles: CircleProps[]) => void;
288 | setPreviousCircles: (circles: CircleProps[]) => void;
289 | setActiveTab: (tab: "design" | "canvas" | "effects") => void;
290 | activeTab: "design" | "canvas" | "effects";
291 | setText: (text: string) => void;
292 | setFontFamily: (fontFamily: string) => void;
293 | setFontSize: (fontSize: number) => void;
294 | setFontWeight: (fontWeight: number) => void;
295 | setLetterSpacing: (letterSpacing: number) => void;
296 | setOpacity: (opacity: number) => void;
297 | setLineHeight: (lineHeight: number) => void;
298 | setBackgroundColor: (backgroundColor: string) => void;
299 | setActiveColorPicker: (color: string) => void;
300 | handleColorChange: (color: string) => void;
301 | setActiveColorType: (colorType: "gradient" | "background" | "text") => void;
302 | setActiveColor: (color: number) => void;
303 | updateColor: (color: string, index: number) => void;
304 | fonts: FontOption[];
305 | activeColorPicker: string;
306 | setTextColor: (textColor: string) => void;
307 | resolution: { width: number; height: number };
308 | setResolution: (res: { width: number; height: number }) => void;
309 | saturation: number;
310 | setSaturation: (value: number) => void;
311 | contrast: number;
312 | setContrast: (value: number) => void;
313 | brightness: number;
314 | setBrightness: (value: number) => void;
315 | blur: number;
316 | setBlur: (value: number) => void;
317 | backgroundImage: string | null;
318 | setBackgroundImage: (backgroundImage: string | null) => void;
319 | handleImageUpload: (e: React.ChangeEvent) => void;
320 | isItalic: boolean;
321 | isUnderline: boolean;
322 | isStrikethrough: boolean;
323 | setIsItalic: (value: boolean) => void;
324 | setIsUnderline: (value: boolean) => void;
325 | setIsStrikethrough: (value: boolean) => void;
326 | numCircles: number;
327 | setNumCircles: (num: number) => void;
328 | colors: string[];
329 | isSafari: boolean;
330 | textShadow: {
331 | color: string;
332 | blur: number;
333 | offsetX: number;
334 | offsetY: number;
335 | };
336 | grainIntensity: number;
337 | setGrainIntensity: (value: number) => void;
338 | setTextShadow: React.Dispatch<
339 | React.SetStateAction<{
340 | color: string;
341 | blur: number;
342 | offsetX: number;
343 | offsetY: number;
344 | }>
345 | >;
346 | isUploading: boolean;
347 | setIsUploading: (isUploading: boolean) => void;
348 | textPosition: { x: number; y: number };
349 | setTextPosition: (position: { x: number; y: number }) => void;
350 | sizeMode: "text" | "image";
351 | logoImage: string | null;
352 | setTextMode: (mode: "text" | "image") => void;
353 | setLogoImage: (image: string | null) => void;
354 | textAlign: "left" | "center" | "right";
355 | setTextAlign: (align: "left" | "center" | "right") => void;
356 | copyImage: () => void;
357 | isCopying: boolean;
358 | setIsCopying: (isCopying: boolean) => void;
359 | handlePaletteChange: () => void;
360 | resetPalette: () => void;
361 | }
362 |
--------------------------------------------------------------------------------
/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Bricolage_Grotesque,
3 | Space_Mono,
4 | Space_Grotesk,
5 | Manrope,
6 | Poppins,
7 | Montserrat,
8 | Onest,
9 | Instrument_Serif,
10 | Inter,
11 | DM_Serif_Display,
12 | Lora,
13 | Geist,
14 | Ms_Madi,
15 | } from "next/font/google";
16 |
17 | // Sans-serif fonts
18 | export const bricolage = Bricolage_Grotesque({
19 | subsets: ["latin"],
20 | variable: "--font-bricolage",
21 | });
22 |
23 | export const geist = Geist({
24 | subsets: ["latin"],
25 | variable: "--font-geist",
26 | });
27 |
28 | export const inter = Inter({
29 | subsets: ["latin"],
30 | variable: "--font-inter",
31 | });
32 |
33 | export const manrope = Manrope({
34 | subsets: ["latin"],
35 | variable: "--font-manrope",
36 | });
37 |
38 | export const montserrat = Montserrat({
39 | subsets: ["latin"],
40 | variable: "--font-montserrat",
41 | });
42 |
43 | export const onest = Onest({
44 | subsets: ["latin"],
45 | variable: "--font-onest",
46 | });
47 |
48 | export const poppins = Poppins({
49 | weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
50 | subsets: ["latin"],
51 | variable: "--font-poppins",
52 | });
53 |
54 | export const spaceGrotesk = Space_Grotesk({
55 | subsets: ["latin"],
56 | variable: "--font-space-grotesk",
57 | });
58 |
59 | // Serif fonts
60 | export const dmSerifDisplay = DM_Serif_Display({
61 | subsets: ["latin"],
62 | variable: "--font-dm-serif-display",
63 | weight: ["400"],
64 | });
65 |
66 | export const instrumentSerif = Instrument_Serif({
67 | weight: ["400"],
68 | subsets: ["latin"],
69 | variable: "--font-instrument-serif",
70 | });
71 |
72 | export const lora = Lora({
73 | subsets: ["latin"],
74 | variable: "--font-lora",
75 | weight: ["400", "500", "600", "700"],
76 | });
77 |
78 | export const msMadi = Ms_Madi({
79 | subsets: ["latin"],
80 | variable: "--font-ms-madi",
81 | weight: ["400"],
82 | });
83 |
84 | // Monospace fonts
85 | export const spaceMono = Space_Mono({
86 | weight: ["400", "700"],
87 | subsets: ["latin"],
88 | variable: "--font-space-mono",
89 | });
90 |
--------------------------------------------------------------------------------
/lib/icons/github.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cn } from "@/lib/utils";
3 | const GithubIcon = (props: React.SVGProps) => (
4 |
12 |
13 |
14 | );
15 |
16 | export default GithubIcon;
17 |
--------------------------------------------------------------------------------
/lib/icons/twitter.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cn } from "@/lib/utils";
3 | const TwitterIcon = (props: React.SVGProps) => (
4 |
11 |
15 |
16 | );
17 |
18 | export default TwitterIcon;
19 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function debounce) => ReturnType>(
9 | fn: T,
10 | ms: number
11 | ): (...args: Parameters) => void {
12 | let timeoutId: ReturnType;
13 | return function (this: ThisParameterType, ...args: Parameters) {
14 | clearTimeout(timeoutId);
15 | timeoutId = setTimeout(() => fn.apply(this, args), ms);
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/lib/utils/effects.ts:
--------------------------------------------------------------------------------
1 | import { createNoise2D } from "simplex-noise";
2 |
3 | export function applyGrainEffect(
4 | ctx: CanvasRenderingContext2D,
5 | intensity: number = 0.15
6 | ) {
7 | const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
8 | const data = imageData.data;
9 | const noise2D = createNoise2D();
10 |
11 | // Reduced amplitude for Safari compatibility
12 | const scale = 1;
13 | const amplitude = 50; // Reduced from 200
14 |
15 | // Process every pixel without gaps
16 | for (let i = 0; i < data.length; i += 4) {
17 | const x = (i / 4) % ctx.canvas.width;
18 | const y = Math.floor(i / 4 / ctx.canvas.width);
19 |
20 | // Generate monochromatic noise
21 | const noise = noise2D(x * scale, y * scale) * (intensity * amplitude);
22 |
23 | // Apply noise as a darker overlay in Safari
24 | const grainValue = Math.max(-30, Math.min(30, noise)); // Limit the range
25 |
26 | // Darken pixels instead of brightening
27 | data[i] = Math.max(0, data[i] + grainValue); // R
28 | data[i + 1] = Math.max(0, data[i + 1] + grainValue); // G
29 | data[i + 2] = Math.max(0, data[i + 2] + grainValue); // B
30 | }
31 |
32 | ctx.putImageData(imageData, 0, 0);
33 | }
34 |
--------------------------------------------------------------------------------
/lib/utils/shapes.ts:
--------------------------------------------------------------------------------
1 | import { CircleProps } from "../constants";
2 |
3 | type ShapeType = "circle" | "blob" | "wave" | "organic";
4 |
5 | interface ShapeProps {
6 | type: ShapeType;
7 | color: string;
8 | x: number;
9 | y: number;
10 | size: number;
11 | }
12 |
13 | export function generateRandomShape(color: string): ShapeProps {
14 | const shapes: ShapeType[] = ["circle", "blob", "wave", "organic"];
15 | const type = shapes[Math.floor(Math.random() * shapes.length)];
16 |
17 | return {
18 | type,
19 | color,
20 | x: Math.random() * 100,
21 | y: Math.random() * 100,
22 | size: 30,
23 | };
24 | }
25 |
26 | export function renderShape(shape: ShapeProps): string {
27 | const generateBlobPath = () => {
28 | const points = 8;
29 | const radius = 30;
30 | const variance = 0.4; // Controls how much the points can deviate
31 |
32 | let path = `M ${shape.x + radius} ${shape.y} `;
33 |
34 | for (let i = 1; i <= points; i++) {
35 | const angle = (i * 2 * Math.PI) / points;
36 | const r = radius * (1 + (Math.random() - 0.5) * variance);
37 | const x = shape.x + r * Math.cos(angle);
38 | const y = shape.y + r * Math.sin(angle);
39 |
40 | const prevAngle = ((i - 1) * 2 * Math.PI) / points;
41 | const cpRadius = radius * (1.2 + Math.random() * 0.4); // Control point radius
42 |
43 | const cp1x = shape.x + cpRadius * Math.cos(prevAngle + Math.PI / points);
44 | const cp1y = shape.y + cpRadius * Math.sin(prevAngle + Math.PI / points);
45 | const cp2x = shape.x + cpRadius * Math.cos(angle - Math.PI / points);
46 | const cp2y = shape.y + cpRadius * Math.sin(angle - Math.PI / points);
47 |
48 | path += `C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x} ${y} `;
49 | }
50 |
51 | return path + "Z";
52 | };
53 |
54 | const generateWavePath = () => {
55 | const width = 60;
56 | const height = 40;
57 | const startX = shape.x - width / 2;
58 | const startY = shape.y;
59 |
60 | let path = `M ${startX} ${startY} `;
61 | path += `C ${startX + width * 0.3} ${startY - height},
62 | ${startX + width * 0.7} ${startY + height},
63 | ${startX + width} ${startY} `;
64 | path += `C ${startX + width * 0.7} ${startY - height / 2},
65 | ${startX + width * 0.3} ${startY + height / 2},
66 | ${startX} ${startY}`;
67 | return path;
68 | };
69 |
70 | const generateOrganicPath = () => {
71 | const points = 12;
72 | const radius = 25 + Math.random() * 15;
73 | let path = "";
74 |
75 | for (let i = 0; i <= points; i++) {
76 | const angle = (i * 2 * Math.PI) / points;
77 | const r = radius * (1 + Math.sin(angle * 3) * 0.3);
78 | const x = shape.x + r * Math.cos(angle);
79 | const y = shape.y + r * Math.sin(angle);
80 |
81 | if (i === 0) path += `M ${x} ${y} `;
82 | else {
83 | const cp1x = shape.x + radius * 1.5 * Math.cos(angle - Math.PI / 6);
84 | const cp1y = shape.y + radius * 1.5 * Math.sin(angle - Math.PI / 6);
85 | path += `Q ${cp1x} ${cp1y}, ${x} ${y} `;
86 | }
87 | }
88 |
89 | return path + "Z";
90 | };
91 |
92 | switch (shape.type) {
93 | case "circle":
94 | return ` `;
95 | case "blob":
96 | return ` `;
99 | case "wave":
100 | return ` `;
103 | case "organic":
104 | return ` `;
107 | }
108 | }
109 |
110 | export function drawShape(
111 | ctx: CanvasRenderingContext2D,
112 | shape: ReturnType,
113 | circle: CircleProps
114 | ) {
115 | const path = new Path2D();
116 |
117 | // Scale coordinates to canvas size
118 | const x = (circle.cx / 100) * ctx.canvas.width;
119 | const y = (circle.cy / 100) * ctx.canvas.height;
120 |
121 | // Generate blob path
122 | const points = 6;
123 | const radius = (30 / 100) * Math.min(ctx.canvas.width, ctx.canvas.height); // Scale radius
124 | const variance = 0.4;
125 |
126 | path.moveTo(x + radius, y);
127 |
128 | for (let i = 1; i <= points; i++) {
129 | const angle = (i * 2 * Math.PI) / points;
130 | const r = radius * (1 + (Math.random() - 0.5) * variance);
131 | const pointX = x + r * Math.cos(angle);
132 | const pointY = y + r * Math.sin(angle);
133 |
134 | const prevAngle = ((i - 1) * 2 * Math.PI) / points;
135 | const cpRadius = radius * (1.2 + Math.random() * 0.4);
136 |
137 | const cp1x = x + cpRadius * Math.cos(prevAngle + Math.PI / points);
138 | const cp1y = y + cpRadius * Math.sin(prevAngle + Math.PI / points);
139 | const cp2x = x + cpRadius * Math.cos(angle - Math.PI / points);
140 | const cp2y = y + cpRadius * Math.sin(angle - Math.PI / points);
141 |
142 | path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, pointX, pointY);
143 | }
144 |
145 | path.closePath();
146 | ctx.fillStyle = circle.color;
147 | ctx.fill(path);
148 | }
149 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wallpaper-app",
3 | "version": "0.6.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@dnd-kit/core": "^6.3.1",
13 | "@microsoft/clarity": "^1.0.0",
14 | "@radix-ui/react-dialog": "^1.1.4",
15 | "@radix-ui/react-dropdown-menu": "^2.1.4",
16 | "@radix-ui/react-label": "^2.1.1",
17 | "@radix-ui/react-popover": "^1.1.6",
18 | "@radix-ui/react-scroll-area": "^1.2.2",
19 | "@radix-ui/react-select": "^2.1.4",
20 | "@radix-ui/react-separator": "^1.1.2",
21 | "@radix-ui/react-slider": "^1.2.2",
22 | "@radix-ui/react-slot": "^1.1.1",
23 | "@radix-ui/react-switch": "^1.1.2",
24 | "@radix-ui/react-tabs": "^1.1.2",
25 | "@silk-hq/components": "^0.8.14",
26 | "@tiptap/extension-color": "^2.11.7",
27 | "@tiptap/extension-font-family": "^2.11.7",
28 | "@tiptap/extension-text-style": "^2.11.7",
29 | "@tiptap/pm": "^2.11.7",
30 | "@tiptap/react": "^2.11.7",
31 | "@tiptap/starter-kit": "^2.11.7",
32 | "@use-gesture/react": "^10.3.1",
33 | "@vercel/analytics": "^1.5.0",
34 | "class-variance-authority": "^0.7.1",
35 | "clsx": "^2.1.1",
36 | "context-filter-polyfill": "^0.3.22",
37 | "framer-motion": "^12.4.3",
38 | "lucide-react": "^0.469.0",
39 | "mit-license": "^1.0.0",
40 | "motion": "^11.15.0",
41 | "next": "15.1.3",
42 | "next-themes": "^0.4.4",
43 | "react": "^19.0.0",
44 | "react-colorful": "^5.6.1",
45 | "react-dnd": "^16.0.1",
46 | "react-dnd-html5-backend": "^16.0.1",
47 | "react-dom": "^19.0.0",
48 | "react-use-controllable-state": "^0.0.8",
49 | "react-zoom-pan-pinch": "^3.6.1",
50 | "simplex-noise": "^4.0.3",
51 | "sonner": "^1.7.1",
52 | "tailwind-merge": "^2.6.0",
53 | "tailwindcss-animate": "^1.0.7",
54 | "vaul": "^1.1.2",
55 | "zustand": "^5.0.3"
56 | },
57 | "devDependencies": {
58 | "@eslint/eslintrc": "^3",
59 | "@tailwindcss/postcss": "^4.0.0",
60 | "@types/node": "^20",
61 | "@types/react": "^19",
62 | "@types/react-dom": "^19",
63 | "eslint": "^9",
64 | "eslint-config-next": "15.1.3",
65 | "postcss": "^8",
66 | "tailwindcss": "^4.0.0",
67 | "typescript": "^5"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | '@tailwindcss/postcss': {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/icons/logo-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keshav-exe/wallpaper-app/eb7573a157d541e81c76800f216f03bf30d61ee1/public/icons/logo-192.png
--------------------------------------------------------------------------------
/public/icons/logo-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keshav-exe/wallpaper-app/eb7573a157d541e81c76800f216f03bf30d61ee1/public/icons/logo-512.png
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Gradii",
3 | "short_name": "Gradii",
4 | "description": "Create beautiful wallpapers with gradients and effects",
5 | "start_url": "/",
6 | "display": "standalone",
7 | "theme_color": "#09090b",
8 | "icons": [
9 | {
10 | "src": "/icons/logo-192.png",
11 | "sizes": "192x192",
12 | "type": "image/png",
13 | "purpose": "any maskable"
14 | },
15 | {
16 | "src": "/icons/logo-512.png",
17 | "sizes": "512x512",
18 | "type": "image/png",
19 | "purpose": "any maskable"
20 | }
21 | ],
22 | "splash_pages": null
23 | }
24 |
--------------------------------------------------------------------------------
/public/service-worker.js:
--------------------------------------------------------------------------------
1 | const CACHE_NAME = "gradii-cache-v1";
2 | const urlsToCache = [
3 | "/",
4 | "/index.html",
5 | "/manifest.json",
6 | "/icons/icon-192x192.png",
7 | "/icons/icon-512x512.png",
8 | ];
9 |
10 | self.addEventListener("install", (event) => {
11 | event.waitUntil(
12 | caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache))
13 | );
14 | });
15 |
16 | self.addEventListener("fetch", (event) => {
17 | event.respondWith(
18 | caches.match(event.request).then((response) => {
19 | if (response) {
20 | return response;
21 | }
22 | return fetch(event.request).then((response) => {
23 | if (!response || response.status !== 200 || response.type !== "basic") {
24 | return response;
25 | }
26 | const responseToCache = response.clone();
27 | caches.open(CACHE_NAME).then((cache) => {
28 | cache.put(event.request, responseToCache);
29 | });
30 | return response;
31 | });
32 | })
33 | );
34 | });
35 |
36 | self.addEventListener("activate", (event) => {
37 | event.waitUntil(
38 | caches.keys().then((cacheNames) => {
39 | return Promise.all(
40 | cacheNames.map((cacheName) => {
41 | if (cacheName !== CACHE_NAME) {
42 | return caches.delete(cacheName);
43 | }
44 | })
45 | );
46 | })
47 | );
48 | });
49 |
--------------------------------------------------------------------------------
/store/wallpaper.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import {
3 | INITIAL_COLORS,
4 | INITIAL_BACKGROUND_COLORS,
5 | type CircleProps,
6 | } from "@/lib/constants";
7 | import { Dispatch, SetStateAction } from "react";
8 | import { debounce } from "@/lib/utils";
9 |
10 | interface WallpaperState {
11 | // Colors and Circles
12 | colors: string[];
13 | backgroundColors: string[];
14 | activeColor: number | null;
15 | circles: CircleProps[];
16 | previousCircles: CircleProps[];
17 | numCircles: number;
18 | backgroundColor: string;
19 |
20 | // Text Properties
21 | text: string;
22 | htmlContent: string;
23 | fontSize: number;
24 | fontWeight: number;
25 | letterSpacing: number;
26 | opacity: number;
27 | fontFamily: string;
28 | lineHeight: number;
29 | textColor: string;
30 | isItalic: boolean;
31 | isUnderline: boolean;
32 | isStrikethrough: boolean;
33 |
34 | // Effects
35 | blur: number;
36 | saturation: number;
37 | contrast: number;
38 | brightness: number;
39 | grainIntensity: number;
40 | textShadow: {
41 | color: string;
42 | blur: number;
43 | offsetX: number;
44 | offsetY: number;
45 | };
46 |
47 | // UI State
48 | activeTab: "design" | "effects" | "canvas";
49 | activeColorPicker: string;
50 | activeColorType: "text" | "background" | "gradient";
51 | resolution: { width: number; height: number };
52 | isDownloading: boolean;
53 | isGenerating: boolean;
54 | isUploading: boolean;
55 | backgroundImage: string | null;
56 |
57 | // Position
58 | textPosition: { x: number; y: number };
59 |
60 | // Add these to WallpaperState interface
61 | sizeMode: "text" | "image";
62 | logoImage: string | null;
63 |
64 | // Text Alignment
65 | textAlign: "left" | "center" | "right";
66 |
67 | // Actions
68 | setCircles: (circles: CircleProps[]) => void;
69 | setPreviousCircles: (circles: CircleProps[]) => void;
70 | setActiveColor: (index: number | null) => void;
71 | updateColor: (newColor: string, index: number) => void;
72 | setText: (text: string) => void;
73 | setHtmlContent: (content: string) => void;
74 | setFontSize: (size: number) => void;
75 | setFontWeight: (weight: number) => void;
76 | setFontFamily: (family: string) => void;
77 | setBackgroundColor: (color: string) => void;
78 | setTextColor: (color: string) => void;
79 | generateNewPalette: () => void;
80 | resetPalette: () => void;
81 |
82 | // Add missing setters
83 | setActiveTab: (tab: "design" | "effects" | "canvas") => void;
84 | setLetterSpacing: (spacing: number) => void;
85 | setOpacity: (opacity: number) => void;
86 | setLineHeight: (height: number) => void;
87 | setBlur: (blur: number) => void;
88 | setSaturation: (saturation: number) => void;
89 | setContrast: (contrast: number) => void;
90 | setBrightness: (brightness: number) => void;
91 | setGrainIntensity: (intensity: number) => void;
92 | setTextShadow: Dispatch<
93 | SetStateAction<{
94 | color: string;
95 | blur: number;
96 | offsetX: number;
97 | offsetY: number;
98 | }>
99 | >;
100 | setIsItalic: (isItalic: boolean) => void;
101 | setIsUnderline: (isUnderline: boolean) => void;
102 | setIsStrikethrough: (isStrikethrough: boolean) => void;
103 | setResolution: (resolution: { width: number; height: number }) => void;
104 | setBackgroundImage: (image: string | null) => void;
105 | setIsUploading: (isUploading: boolean) => void;
106 | setNumCircles: (num: number) => void;
107 | setActiveColorPicker: (color: string) => void;
108 | setActiveColorType: (type: "text" | "background" | "gradient") => void;
109 | setIsDownloading: (isDownloading: boolean) => void;
110 | setTextPosition: (textPosition: { x: number; y: number }) => void;
111 |
112 | // Add these actions
113 | setTextMode: (mode: "text" | "image") => void;
114 | setLogoImage: (image: string | null) => void;
115 |
116 | // Add missing setters
117 | setTextAlign: (align: "left" | "center" | "right") => void;
118 |
119 | // Add to WallpaperState interface
120 | isCopying: boolean;
121 | setIsCopying: (isCopying: boolean) => void;
122 | }
123 |
124 | export const useWallpaperStore = create((set, get) => ({
125 | // Initial state
126 | colors: INITIAL_COLORS,
127 | backgroundColors: INITIAL_BACKGROUND_COLORS,
128 | activeColor: null,
129 | circles: INITIAL_COLORS.map((color) => ({
130 | color,
131 | cx: Math.random() * 100,
132 | cy: Math.random() * 100,
133 | })),
134 | previousCircles: [],
135 | numCircles: INITIAL_COLORS.length,
136 | text: "Gradii.",
137 | htmlContent: "Gradii.
",
138 | fontSize: 10,
139 | blur: 600,
140 | fontWeight: 600,
141 | letterSpacing: -0.02,
142 | opacity: 100,
143 | fontFamily: "Onest",
144 | activeTab: "design",
145 | grainIntensity: 25,
146 | backgroundColor: "#001220",
147 | lineHeight: 1,
148 | textColor: "#f1f1f1",
149 | activeColorPicker: "#f1f1f1",
150 | activeColorType: "text",
151 | resolution: { width: 1920, height: 1080 },
152 | saturation: 100,
153 | contrast: 100,
154 | brightness: 100,
155 | backgroundImage: null,
156 | isItalic: false,
157 | isUnderline: false,
158 | isStrikethrough: false,
159 | isDownloading: false,
160 | isGenerating: false,
161 | isUploading: false,
162 | textShadow: {
163 | color: "#f5f5f5",
164 | blur: 24,
165 | offsetX: 0,
166 | offsetY: 0,
167 | },
168 | textPosition: { x: 0, y: 0 },
169 |
170 | // Add to initial state
171 | sizeMode: "text",
172 | logoImage: null,
173 |
174 | // Text Alignment
175 | textAlign: "center",
176 |
177 | // Actions
178 | setCircles: (circles) => {
179 | // Check for overlapping circles and reposition if needed
180 | const repositionedCircles = circles.map((circle, index) => {
181 | const overlapping = circles.some((other, otherIndex) => {
182 | if (index === otherIndex) return false;
183 | const distance = Math.sqrt(
184 | Math.pow(circle.cx - other.cx, 2) + Math.pow(circle.cy - other.cy, 2)
185 | );
186 | return distance < 20; // Threshold for overlap
187 | });
188 |
189 | if (overlapping) {
190 | // Try to find a non-overlapping position
191 | let attempts = 0;
192 | let newCx = circle.cx;
193 | let newCy = circle.cy;
194 |
195 | while (attempts < 10) {
196 | newCx = Math.random() * 100;
197 | newCy = Math.random() * 100;
198 |
199 | const hasOverlap = circles.some((other, otherIndex) => {
200 | if (index === otherIndex) return false;
201 | const distance = Math.sqrt(
202 | Math.pow(newCx - other.cx, 2) + Math.pow(newCy - other.cy, 2)
203 | );
204 | return distance < 20;
205 | });
206 |
207 | if (!hasOverlap) break;
208 | attempts++;
209 | }
210 |
211 | return { ...circle, cx: newCx, cy: newCy };
212 | }
213 |
214 | return circle;
215 | });
216 |
217 | set({ circles: repositionedCircles });
218 | },
219 | setPreviousCircles: (circles) => set({ previousCircles: circles }),
220 | setActiveColor: (index) => set({ activeColor: index }),
221 | updateColor: (newColor, index) => {
222 | const { circles } = get();
223 | const newCircles = [...circles];
224 | newCircles[index] = {
225 | ...newCircles[index],
226 | color: newColor,
227 | };
228 | set({ circles: newCircles });
229 | },
230 | setText: (text) => set({ text }),
231 | setHtmlContent: (content) => set({ htmlContent: content }),
232 | setFontSize: debounce((size: number) => set({ fontSize: size }), 100),
233 | setFontWeight: (weight) => set({ fontWeight: weight }),
234 | setFontFamily: (family) => set({ fontFamily: family }),
235 | setBackgroundColor: (color) => set({ backgroundColor: color }),
236 | setTextColor: (color) => set({ textColor: color }),
237 |
238 | generateNewPalette: () => {
239 | const { circles } = get();
240 | set({
241 | previousCircles: circles,
242 | circles: circles.map((circle) => ({
243 | ...circle,
244 | cx: Math.random() * 100,
245 | cy: Math.random() * 100,
246 | })),
247 | });
248 | },
249 |
250 | setActiveTab: (tab) => set({ activeTab: tab }),
251 | setLetterSpacing: debounce(
252 | (spacing: number) => set({ letterSpacing: spacing }),
253 | 100
254 | ),
255 | setOpacity: (opacity) => set({ opacity }),
256 | setLineHeight: debounce((height: number) => set({ lineHeight: height }), 100),
257 | setBlur: debounce((blur: number) => set({ blur }), 100),
258 | setSaturation: debounce((saturation: number) => set({ saturation }), 100),
259 | setContrast: debounce((contrast: number) => set({ contrast }), 100),
260 | setBrightness: debounce((brightness: number) => set({ brightness }), 100),
261 | setGrainIntensity: debounce(
262 | (grainIntensity: number) => set({ grainIntensity }),
263 | 100
264 | ),
265 | setTextShadow: (value) =>
266 | set({
267 | textShadow: typeof value === "function" ? value(get().textShadow) : value,
268 | }),
269 | setIsItalic: (isItalic) => set({ isItalic }),
270 | setIsUnderline: (isUnderline) => set({ isUnderline }),
271 | setIsStrikethrough: (isStrikethrough) => set({ isStrikethrough }),
272 | setResolution: (resolution) => set({ resolution }),
273 | setBackgroundImage: (image) => set({ backgroundImage: image }),
274 | setIsUploading: (isUploading) => set({ isUploading }),
275 | setNumCircles: (num) => set({ numCircles: num }),
276 | setActiveColorPicker: (color) => set({ activeColorPicker: color }),
277 | setActiveColorType: (type) => set({ activeColorType: type }),
278 | setIsDownloading: (isDownloading) => set({ isDownloading }),
279 | setTextPosition: (textPosition) => set({ textPosition }),
280 | setTextMode: (mode) => set({ sizeMode: mode }),
281 | setLogoImage: (image) => set({ logoImage: image }),
282 | setTextAlign: (align) => set({ textAlign: align }),
283 | isCopying: false,
284 | setIsCopying: (isCopying) => set({ isCopying }),
285 | resetPalette: () => {
286 | const { circles } = get();
287 | const newCircles = INITIAL_COLORS.map((color, index) => ({
288 | color,
289 | cx: circles[index]?.cx ?? Math.random() * 100,
290 | cy: circles[index]?.cy ?? Math.random() * 100,
291 | }));
292 |
293 | // Check for overlapping circles and reposition if needed
294 | const repositionedCircles = newCircles.map((circle, index) => {
295 | const overlapping = newCircles.some((other, otherIndex) => {
296 | if (index === otherIndex) return false;
297 | const distance = Math.sqrt(
298 | Math.pow(circle.cx - other.cx, 2) + Math.pow(circle.cy - other.cy, 2)
299 | );
300 | return distance < 20;
301 | });
302 |
303 | if (overlapping) {
304 | let attempts = 0;
305 | let newCx = circle.cx;
306 | let newCy = circle.cy;
307 |
308 | while (attempts < 10) {
309 | newCx = Math.random() * 100;
310 | newCy = Math.random() * 100;
311 |
312 | const hasOverlap = newCircles.some((other, otherIndex) => {
313 | if (index === otherIndex) return false;
314 | const distance = Math.sqrt(
315 | Math.pow(newCx - other.cx, 2) + Math.pow(newCy - other.cy, 2)
316 | );
317 | return distance < 20;
318 | });
319 |
320 | if (!hasOverlap) break;
321 | attempts++;
322 | }
323 |
324 | return { ...circle, cx: newCx, cy: newCy };
325 | }
326 |
327 | return circle;
328 | });
329 |
330 | set({
331 | textColor: "#f1f1f1",
332 | backgroundColor: "#001220",
333 | circles: repositionedCircles,
334 | numCircles: INITIAL_COLORS.length,
335 | });
336 | },
337 | }));
338 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | darkMode: ["class", "system"],
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: "hsl(var(--background))",
14 | foreground: "hsl(var(--foreground))",
15 | card: {
16 | DEFAULT: "hsl(var(--card))",
17 | foreground: "hsl(var(--card-foreground))",
18 | },
19 | popover: {
20 | DEFAULT: "hsl(var(--popover))",
21 | foreground: "hsl(var(--popover-foreground))",
22 | },
23 | primary: {
24 | DEFAULT: "hsl(var(--primary))",
25 | foreground: "hsl(var(--primary-foreground))",
26 | },
27 | secondary: {
28 | DEFAULT: "hsl(var(--secondary))",
29 | foreground: "hsl(var(--secondary-foreground))",
30 | },
31 | muted: {
32 | DEFAULT: "hsl(var(--muted))",
33 | foreground: "hsl(var(--muted-foreground))",
34 | },
35 | accent: {
36 | DEFAULT: "hsl(var(--accent))",
37 | foreground: "hsl(var(--accent-foreground))",
38 | },
39 | destructive: {
40 | DEFAULT: "hsl(var(--destructive))",
41 | foreground: "hsl(var(--destructive-foreground))",
42 | },
43 | border: "hsl(var(--border))",
44 | input: "hsl(var(--input))",
45 | ring: "hsl(var(--ring))",
46 | chart: {
47 | "1": "hsl(var(--chart-1))",
48 | "2": "hsl(var(--chart-2))",
49 | "3": "hsl(var(--chart-3))",
50 | "4": "hsl(var(--chart-4))",
51 | "5": "hsl(var(--chart-5))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | marquee: {
61 | from: {
62 | transform: "translateX(0)",
63 | },
64 | to: {
65 | transform: "translateX(calc(-100% - var(--gap)))",
66 | },
67 | },
68 | "marquee-vertical": {
69 | from: {
70 | transform: "translateY(0)",
71 | },
72 | to: {
73 | transform: "translateY(calc(-100% - var(--gap)))",
74 | },
75 | },
76 | "accordion-down": {
77 | from: {
78 | height: "0",
79 | },
80 | to: {
81 | height: "var(--radix-accordion-content-height)",
82 | },
83 | },
84 | "accordion-up": {
85 | from: {
86 | height: "var(--radix-accordion-content-height)",
87 | },
88 | to: {
89 | height: "0",
90 | },
91 | },
92 | },
93 | animation: {
94 | marquee: "marquee var(--duration) infinite linear",
95 | "marquee-vertical": "marquee-vertical var(--duration) linear infinite",
96 | "accordion-down": "accordion-down 0.2s ease-out",
97 | "accordion-up": "accordion-up 0.2s ease-out",
98 | },
99 | },
100 | },
101 | } satisfies Config;
102 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------