mirror of
https://github.com/Xevion/grain.git
synced 2025-12-06 01:15:10 -06:00
refactor: simplify export to PNG-only, improve controls UI
- Replace html2canvas with modern-screenshot for better performance - Remove ExportModal and CSS export functionality - Simplify export to single PNG download - Improve controls layout with better spacing and text shadows - Increase glass-panel opacity for better readability - Remove unused ExportData interface and CSS generation utilities
This commit is contained in:
37
README.md
37
README.md
@@ -1,29 +1,32 @@
|
|||||||
![Grain Project - Banner Image][grain-banner]
|
[![Grain Project - Banner Image][grain-banner]][grain-website]
|
||||||
|
|
||||||
A small experiment on creating beautiful, dynamic backgrounds with
|
[](LICENSE)
|
||||||
colorful gradients & film grain. Built in <b>React</b> & <b>Vite</b> with <b>SVGs</b> and layers of <b>Radial Gradients</b>
|
[](https://nodejs.org/)
|
||||||
|
[](https://pnpm.io/)
|
||||||
|
|
||||||
### Dependencies Used
|
Create beautiful, dynamic backgrounds with colorful gradients and film grain. Built with modern web technologies for smooth performance and stunning visuals.
|
||||||
|
|
||||||
- Hero Icons
|
- **Customizable gradients & noise** via SVG filters
|
||||||
- React
|
- **Preset palettes** for quick styling
|
||||||
- Typescript
|
- **PNG export** with a single click
|
||||||
- Vite
|
|
||||||
- Sass
|
|
||||||
|
|
||||||
### Installation
|
Built with [Preact](https://preactjs.com/), [Vite](https://vite.dev/), [Tailwind CSS](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), and [Lucide Icons](https://lucide.dev/).
|
||||||
|
|
||||||
- Built on Node v16, packages managed with Yarn.
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --global yarn # If you don't have yarn installed
|
git clone https://github.com/Xevion/grain.git # Clone the repository
|
||||||
yarn # Run inside root directory to install all dependencies.
|
cd grain # Navigate to the repository
|
||||||
|
npm install --global pnpm # Install pnpm if needed
|
||||||
|
pnpm install # Install dependencies
|
||||||
|
pnpm dev # Start development server
|
||||||
|
pnpm build # Build for production
|
||||||
|
pnpm preview # Preview the production build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
## License
|
||||||
|
|
||||||
```bash
|
Licensed under the [GNU General Public License v3.0](LICENSE).
|
||||||
yarn dev # Starts a development server with Hot Module Replacement
|
|
||||||
```
|
|
||||||
|
|
||||||
[grain-banner]: ./.media/banner.jpeg
|
[grain-banner]: ./.media/banner.jpeg
|
||||||
|
[grain-website]: https://grain.xevion.dev/
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"cssnano": "^7.1.0",
|
"cssnano": "^7.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"html2canvas": "^1.4.1",
|
|
||||||
"lucide-preact": "^0.468.0",
|
"lucide-preact": "^0.468.0",
|
||||||
|
"modern-screenshot": "^4.6.6",
|
||||||
"preact": "^10.27.2",
|
"preact": "^10.27.2",
|
||||||
"preact-iso": "^2.11.0",
|
"preact-iso": "^2.11.0",
|
||||||
"preact-render-to-string": "^6.6.3",
|
"preact-render-to-string": "^6.6.3",
|
||||||
|
|||||||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
@@ -41,12 +41,12 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.23.24
|
specifier: ^12.23.24
|
||||||
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
html2canvas:
|
|
||||||
specifier: ^1.4.1
|
|
||||||
version: 1.4.1
|
|
||||||
lucide-preact:
|
lucide-preact:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(preact@10.27.2)
|
version: 0.468.0(preact@10.27.2)
|
||||||
|
modern-screenshot:
|
||||||
|
specifier: ^4.6.6
|
||||||
|
version: 4.6.6
|
||||||
preact:
|
preact:
|
||||||
specifier: ^10.27.2
|
specifier: ^10.27.2
|
||||||
version: 10.27.2
|
version: 10.27.2
|
||||||
@@ -1297,10 +1297,6 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
base64-arraybuffer@1.0.2:
|
|
||||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
|
||||||
engines: {node: '>= 0.6.0'}
|
|
||||||
|
|
||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
@@ -1384,9 +1380,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.0.9
|
postcss: ^8.0.9
|
||||||
|
|
||||||
css-line-break@2.1.0:
|
|
||||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
|
||||||
|
|
||||||
css-select@5.2.2:
|
css-select@5.2.2:
|
||||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||||
|
|
||||||
@@ -1831,10 +1824,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
html2canvas@1.4.1:
|
|
||||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
|
||||||
engines: {node: '>=8.0.0'}
|
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -2159,6 +2148,9 @@ packages:
|
|||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||||
|
|
||||||
|
modern-screenshot@4.6.6:
|
||||||
|
resolution: {integrity: sha512-8tF0xEpe7yx37mK95UcIghSCWYeu628K2hLJl+ZNY2ANmRzYLlRLpquPHAQcL8keF6BoeEzTEw4GrgmUpGuZ8w==}
|
||||||
|
|
||||||
motion-dom@12.23.23:
|
motion-dom@12.23.23:
|
||||||
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
|
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
|
||||||
|
|
||||||
@@ -2734,9 +2726,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
text-segmentation@1.0.3:
|
|
||||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -2829,9 +2818,6 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
utrie@1.0.2:
|
|
||||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
|
||||||
|
|
||||||
vite-prerender-plugin@0.5.11:
|
vite-prerender-plugin@0.5.11:
|
||||||
resolution: {integrity: sha512-xWOhb8Ef2zoJIiinYVunIf3omRfUbEXcPEvrkQcrDpJ2yjDokxhvQ26eSJbkthRhymntWx6816jpATrJphh+ug==}
|
resolution: {integrity: sha512-xWOhb8Ef2zoJIiinYVunIf3omRfUbEXcPEvrkQcrDpJ2yjDokxhvQ26eSJbkthRhymntWx6816jpATrJphh+ug==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4004,8 +3990,6 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
base64-arraybuffer@1.0.2: {}
|
|
||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
@@ -4095,10 +4079,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
|
|
||||||
css-line-break@2.1.0:
|
|
||||||
dependencies:
|
|
||||||
utrie: 1.0.2
|
|
||||||
|
|
||||||
css-select@5.2.2:
|
css-select@5.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
@@ -4711,11 +4691,6 @@ snapshots:
|
|||||||
|
|
||||||
he@1.2.0: {}
|
he@1.2.0: {}
|
||||||
|
|
||||||
html2canvas@1.4.1:
|
|
||||||
dependencies:
|
|
||||||
css-line-break: 2.1.0
|
|
||||||
text-segmentation: 1.0.3
|
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
immutable@5.1.4:
|
immutable@5.1.4:
|
||||||
@@ -5007,6 +4982,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.12
|
brace-expansion: 1.1.12
|
||||||
|
|
||||||
|
modern-screenshot@4.6.6: {}
|
||||||
|
|
||||||
motion-dom@12.23.23:
|
motion-dom@12.23.23:
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-utils: 12.23.6
|
motion-utils: 12.23.6
|
||||||
@@ -5626,10 +5603,6 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
text-segmentation@1.0.3:
|
|
||||||
dependencies:
|
|
||||||
utrie: 1.0.2
|
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
@@ -5723,10 +5696,6 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
utrie@1.0.2:
|
|
||||||
dependencies:
|
|
||||||
base64-arraybuffer: 1.0.2
|
|
||||||
|
|
||||||
vite-prerender-plugin@0.5.11(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(yaml@2.8.1)):
|
vite-prerender-plugin@0.5.11(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(yaml@2.8.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
kolorist: 1.8.0
|
kolorist: 1.8.0
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import { useState } from "preact/hooks";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
|
||||||
import { X, Download, Copy, Check } from "lucide-preact";
|
|
||||||
import { GlassButton } from "./GlassButton";
|
|
||||||
import { exportAsPNG, generateCSS, copyCSSToClipboard, downloadCSS, type ExportData } from "../utils/exportGradient";
|
|
||||||
|
|
||||||
interface ExportModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
exportData: ExportData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExportModal({ open, onClose, exportData }: ExportModalProps) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
|
|
||||||
const cssCode = generateCSS(exportData);
|
|
||||||
|
|
||||||
const handleCopyCSS = async () => {
|
|
||||||
try {
|
|
||||||
await copyCSSToClipboard(cssCode);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to copy:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadCSS = () => {
|
|
||||||
downloadCSS(cssCode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportPNG = async () => {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
await exportAsPNG("gradient-container");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to export PNG:", error);
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50" />
|
|
||||||
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-lg">
|
|
||||||
<div className="glass-panel p-6 m-4 space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Dialog.Title className="font-space-grotesk text-2xl font-bold text-zinc-900">
|
|
||||||
Export Gradient
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Close asChild>
|
|
||||||
<button
|
|
||||||
className="glass-button rounded-lg p-2 text-zinc-900"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</Dialog.Close>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PNG Export Section */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
|
|
||||||
Export as Image
|
|
||||||
</h3>
|
|
||||||
<GlassButton
|
|
||||||
onClick={handleExportPNG}
|
|
||||||
disabled={exporting}
|
|
||||||
className="w-full flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Download size={18} />
|
|
||||||
<span>{exporting ? "Exporting..." : "Download PNG"}</span>
|
|
||||||
</GlassButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CSS Export Section */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
|
|
||||||
Export as CSS
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* CSS Code Preview */}
|
|
||||||
<div className="glass-input rounded-lg p-4 overflow-x-auto max-h-48 overflow-y-auto">
|
|
||||||
<pre className="font-mono text-xs text-zinc-900 leading-relaxed">
|
|
||||||
{cssCode}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CSS Actions */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<GlassButton
|
|
||||||
onClick={handleCopyCSS}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{copied ? <Check size={18} /> : <Copy size={18} />}
|
|
||||||
<span>{copied ? "Copied!" : "Copy CSS"}</span>
|
|
||||||
</GlassButton>
|
|
||||||
<GlassButton
|
|
||||||
onClick={handleDownloadCSS}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Download size={18} />
|
|
||||||
<span>Download CSS</span>
|
|
||||||
</GlassButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -28,23 +28,23 @@ export function GradientControls({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-6">
|
<div className="pl-4 pt-3 pb-2">
|
||||||
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-900">
|
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-50 text-shadow-md">
|
||||||
Grain
|
Grain
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator.Root className="h-px bg-white/20 mx-6" />
|
<Separator.Root className="h-px bg-white/20" />
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
|
||||||
{/* Palette Selector */}
|
{/* Palette Selector */}
|
||||||
<PaletteSelector
|
<PaletteSelector
|
||||||
|
className="px-6 py-4"
|
||||||
selectedPaletteId={paletteId}
|
selectedPaletteId={paletteId}
|
||||||
onSelect={onPaletteChange}
|
onSelect={onPaletteChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator.Root className="h-px bg-white/10" />
|
<Separator.Root className="h-px bg-white/20" />
|
||||||
|
|
||||||
{/* Gradient Count Slider */}
|
{/* Gradient Count Slider */}
|
||||||
<SliderControl
|
<SliderControl
|
||||||
@@ -54,6 +54,7 @@ export function GradientControls({
|
|||||||
min={2}
|
min={2}
|
||||||
max={8}
|
max={8}
|
||||||
step={1}
|
step={1}
|
||||||
|
className="px-6 py-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Noise Intensity Slider */}
|
{/* Noise Intensity Slider */}
|
||||||
@@ -65,12 +66,13 @@ export function GradientControls({
|
|||||||
max={1.5}
|
max={1.5}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
formatValue={(v) => `${Math.round(v * 100)}%`}
|
formatValue={(v) => `${Math.round(v * 100)}%`}
|
||||||
|
className="px-6 py-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator.Root className="h-px bg-white/10" />
|
<Separator.Root className="h-px bg-white/20" />
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-2 mx-6 my-4">
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onClick={onRegenerate}
|
onClick={onRegenerate}
|
||||||
className="w-full flex items-center justify-center gap-2"
|
className="w-full flex items-center justify-center gap-2"
|
||||||
@@ -92,6 +94,5 @@ export function GradientControls({
|
|||||||
</GlassButton>
|
</GlassButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { motion } from "framer-motion";
|
|||||||
interface PaletteSelectorProps {
|
interface PaletteSelectorProps {
|
||||||
selectedPaletteId: string;
|
selectedPaletteId: string;
|
||||||
onSelect: (paletteId: string) => void;
|
onSelect: (paletteId: string) => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaletteSelector({ selectedPaletteId, onSelect }: PaletteSelectorProps) {
|
export function PaletteSelector({
|
||||||
|
selectedPaletteId,
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
}: PaletteSelectorProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className={`space-y-2 ${className}`}>
|
||||||
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
|
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-50 text-shadow-md">
|
||||||
Color Palette
|
Color Palette
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface SliderControlProps {
|
|||||||
max: number;
|
max: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
formatValue?: (value: number) => string;
|
formatValue?: (value: number) => string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SliderControl({
|
export function SliderControl({
|
||||||
@@ -17,15 +18,16 @@ export function SliderControl({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step = 1,
|
step = 1,
|
||||||
formatValue = (v) => v.toString()
|
formatValue = (v) => v.toString(),
|
||||||
|
className,
|
||||||
}: SliderControlProps) {
|
}: SliderControlProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className={`space-y ${className}`}>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
|
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-50 text-shadow-md">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<span className="font-inter text-sm text-zinc-800">
|
<span className="font-inter text-sm text-zinc-100 text-shadow-md">
|
||||||
{formatValue(value)}
|
{formatValue(value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +39,7 @@ export function SliderControl({
|
|||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
>
|
>
|
||||||
<Slider.Track className="glass-input relative flex-grow rounded-full h-3">
|
<Slider.Track className="glass-input relative grow rounded-full h-3">
|
||||||
<Slider.Range className="absolute bg-white/40 rounded-full h-full" />
|
<Slider.Range className="absolute bg-white/40 rounded-full h-full" />
|
||||||
</Slider.Track>
|
</Slider.Track>
|
||||||
<Slider.Thumb
|
<Slider.Thumb
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ pre.inline {
|
|||||||
|
|
||||||
/* Glassmorphism utilities */
|
/* Glassmorphism utilities */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
backdrop-filter: blur(50px) saturate(100%);
|
backdrop-filter: blur(50px) saturate(100%);
|
||||||
/* -webkit-backdrop-filter: blur(10px) saturate(80%); */
|
/* -webkit-backdrop-filter: blur(10px) saturate(80%); */
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { useViewportSize } from "./utils/useViewportSize";
|
|||||||
import useBackground from "./utils/useBackground";
|
import useBackground from "./utils/useBackground";
|
||||||
import { GlassPanel } from "./components/GlassPanel";
|
import { GlassPanel } from "./components/GlassPanel";
|
||||||
import { GradientControls } from "./components/GradientControls";
|
import { GradientControls } from "./components/GradientControls";
|
||||||
import { ExportModal } from "./components/ExportModal";
|
|
||||||
import { getPaletteById } from "./utils/palettes";
|
import { getPaletteById } from "./utils/palettes";
|
||||||
import type { ExportData } from "./utils/exportGradient";
|
import { exportGradientAsPNG } from "./utils/exportGradient";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { width, height } = useViewportSize();
|
const { width, height } = useViewportSize();
|
||||||
@@ -18,7 +17,6 @@ export function App() {
|
|||||||
const [paletteId, setPaletteId] = useState("classic");
|
const [paletteId, setPaletteId] = useState("classic");
|
||||||
const [gradientCount, setGradientCount] = useState(5);
|
const [gradientCount, setGradientCount] = useState(5);
|
||||||
const [noiseIntensity, setNoiseIntensity] = useState(0.9);
|
const [noiseIntensity, setNoiseIntensity] = useState(0.9);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const palette = getPaletteById(paletteId);
|
const palette = getPaletteById(paletteId);
|
||||||
|
|
||||||
@@ -37,12 +35,6 @@ export function App() {
|
|||||||
};
|
};
|
||||||
}, [svg, backgrounds]);
|
}, [svg, backgrounds]);
|
||||||
|
|
||||||
const exportData: ExportData = useMemo(() => ({
|
|
||||||
backgrounds,
|
|
||||||
svg,
|
|
||||||
noiseIntensity,
|
|
||||||
}), [backgrounds, svg, noiseIntensity]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyPress = (e: KeyboardEvent) => {
|
const handleKeyPress = (e: KeyboardEvent) => {
|
||||||
@@ -51,7 +43,7 @@ export function App() {
|
|||||||
regenerate();
|
regenerate();
|
||||||
} else if (e.key === "e" || e.key === "E") {
|
} else if (e.key === "e" || e.key === "E") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setExportModalOpen(true);
|
exportGradientAsPNG();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,17 +73,10 @@ export function App() {
|
|||||||
noiseIntensity={noiseIntensity}
|
noiseIntensity={noiseIntensity}
|
||||||
onNoiseIntensityChange={setNoiseIntensity}
|
onNoiseIntensityChange={setNoiseIntensity}
|
||||||
onRegenerate={regenerate}
|
onRegenerate={regenerate}
|
||||||
onExport={() => setExportModalOpen(true)}
|
onExport={() => exportGradientAsPNG()}
|
||||||
/>
|
/>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Export modal */}
|
|
||||||
<ExportModal
|
|
||||||
open={exportModalOpen}
|
|
||||||
onClose={() => setExportModalOpen(false)}
|
|
||||||
exportData={exportData}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,20 @@
|
|||||||
import html2canvas from "html2canvas";
|
import { domToPng } from "modern-screenshot";
|
||||||
|
|
||||||
export interface ExportData {
|
|
||||||
backgrounds: string[];
|
|
||||||
svg: string;
|
|
||||||
noiseIntensity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export the current gradient as a PNG image
|
* Export the gradient as a PNG screenshot
|
||||||
*/
|
*/
|
||||||
export async function exportAsPNG(elementId: string = "gradient-container"): Promise<void> {
|
export async function exportGradientAsPNG(
|
||||||
const element = document.getElementById(elementId);
|
elementId: string = "gradient-container"
|
||||||
|
): Promise<void> {
|
||||||
|
const element = document.querySelector(`#${elementId}`);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error("Gradient element not found");
|
throw new Error("Gradient element not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const dataUrl = await domToPng(element as HTMLElement);
|
||||||
const canvas = await html2canvas(element, {
|
|
||||||
backgroundColor: null,
|
|
||||||
scale: 2, // Higher quality
|
|
||||||
logging: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (!blob) return;
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
|
||||||
link.download = `grain-gradient-${Date.now()}.png`;
|
link.download = `grain-gradient-${Date.now()}.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to export PNG:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate CSS code for the current gradient
|
|
||||||
*/
|
|
||||||
export function generateCSS(data: ExportData): string {
|
|
||||||
const { backgrounds, svg } = data;
|
|
||||||
|
|
||||||
const backgroundLayers = [`url("${svg}")`, ...backgrounds].join(",\n ");
|
|
||||||
|
|
||||||
return `/* Grain Gradient CSS */
|
|
||||||
.gradient-background {
|
|
||||||
background: ${backgroundLayers};
|
|
||||||
filter: contrast(150%) brightness(90%);
|
|
||||||
background-blend-mode: overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional overlay (optional) */
|
|
||||||
.gradient-overlay {
|
|
||||||
background: rgba(40, 40, 40, 0.5);
|
|
||||||
background-blend-mode: overlay;
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy CSS code to clipboard
|
|
||||||
*/
|
|
||||||
export async function copyCSSToClipboard(css: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(css);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to copy to clipboard:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download CSS code as a file
|
|
||||||
*/
|
|
||||||
export function downloadCSS(css: string): void {
|
|
||||||
const blob = new Blob([css], { type: "text/css" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = `grain-gradient-${Date.now()}.css`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user