- Add clickable favorites counter with expandable panel - Render saved recipes list with remove button - Remove duplicate 'Another one' button (Generate already covers it) - Sync favorite button state when removing from panel
301 lines
12 KiB
Plaintext
301 lines
12 KiB
Plaintext
---
|
|
import { useT } from "../../i18n/translations";
|
|
const isRu = Astro.url.pathname.startsWith("/ru");
|
|
const T = useT(isRu ? "ru" : "en");
|
|
|
|
const i18n = {
|
|
typeLabel: isRu ? "Тип градиента" : "Gradient type",
|
|
stopsLabel: isRu ? "Количество цветов" : "Color stops",
|
|
generate: T.generate,
|
|
copied: T.copied,
|
|
copy: T.copy,
|
|
cssCode: isRu ? "CSS-код" : "CSS code",
|
|
colorStops: isRu ? "Цветовые точки" : "Color stops",
|
|
types: {
|
|
linear: isRu ? "Линейный" : "Linear",
|
|
radial: isRu ? "Радиальный" : "Radial",
|
|
conic: isRu ? "Конический" : "Conic",
|
|
},
|
|
};
|
|
---
|
|
|
|
<div id="gradient-generator" class="mt-8">
|
|
<!-- Controls -->
|
|
<div class="flex flex-col sm:flex-row gap-4 mb-6">
|
|
<div class="flex-1">
|
|
<label for="gg-type" class="block text-sm font-medium text-zinc-400 mb-2">
|
|
{i18n.typeLabel}
|
|
</label>
|
|
<select
|
|
id="gg-type"
|
|
class="w-full px-4 py-2.5 bg-zinc-900/80 border border-zinc-800/80 rounded-xl text-zinc-100 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
|
|
>
|
|
<option value="linear">{i18n.types.linear}</option>
|
|
<option value="radial">{i18n.types.radial}</option>
|
|
<option value="conic">{i18n.types.conic}</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex-1">
|
|
<label for="gg-stops" class="block text-sm font-medium text-zinc-400 mb-2">
|
|
{i18n.stopsLabel}
|
|
</label>
|
|
<select
|
|
id="gg-stops"
|
|
class="w-full px-4 py-2.5 bg-zinc-900/80 border border-zinc-800/80 rounded-xl text-zinc-100 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 cursor-pointer"
|
|
>
|
|
<option value="2" selected>2</option>
|
|
<option value="3">3</option>
|
|
<option value="4">4</option>
|
|
<option value="5">5</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Result container -->
|
|
<div
|
|
id="gg-result"
|
|
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-8 opacity-0"
|
|
style="transition: opacity 0.25s ease;"
|
|
aria-live="polite"
|
|
>
|
|
<!-- Gradient Preview -->
|
|
<div class="mb-6">
|
|
<div
|
|
id="gg-preview"
|
|
class="w-full h-48 sm:h-64 rounded-2xl border border-zinc-800"
|
|
style="transition: background 0.35s ease;"
|
|
aria-hidden="true"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CSS Code -->
|
|
<div class="mb-6">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-medium text-zinc-400">{i18n.cssCode}</span>
|
|
<button
|
|
id="gg-copy-css"
|
|
type="button"
|
|
class="group shrink-0 px-3 py-1.5 bg-zinc-800 text-zinc-300 text-sm font-medium rounded-lg border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer inline-flex items-center gap-1.5"
|
|
>
|
|
<svg id="gg-copy-css-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
|
<svg id="gg-check-css-icon" class="hidden text-accent" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
|
<span id="gg-copy-css-label">{i18n.copy}</span>
|
|
</button>
|
|
</div>
|
|
<div class="relative">
|
|
<code
|
|
id="gg-css-code"
|
|
class="block w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl font-mono text-sm text-zinc-200 whitespace-pre-wrap break-all"
|
|
></code>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Color Stops -->
|
|
<div>
|
|
<span class="text-sm font-medium text-zinc-400 mb-3 block">{i18n.colorStops}</span>
|
|
<div id="gg-color-stops" class="flex flex-wrap gap-3">
|
|
<!-- Color stop chips injected by JS -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Generate button -->
|
|
<div class="flex justify-center mt-6">
|
|
<button
|
|
id="gg-btn"
|
|
type="button"
|
|
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
|
|
>
|
|
{i18n.generate}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const isRu = document.documentElement.lang === "ru";
|
|
|
|
const typeSelect = document.getElementById("gg-type") as HTMLSelectElement;
|
|
const stopsSelect = document.getElementById("gg-stops") as HTMLSelectElement;
|
|
const resultEl = document.getElementById("gg-result") as HTMLDivElement;
|
|
const previewEl = document.getElementById("gg-preview") as HTMLDivElement;
|
|
const cssCodeEl = document.getElementById("gg-css-code") as HTMLElement;
|
|
const copyCssBtn = document.getElementById("gg-copy-css") as HTMLButtonElement;
|
|
const copyCssLabel = document.getElementById("gg-copy-css-label") as HTMLSpanElement;
|
|
const copyCssIcon = document.getElementById("gg-copy-css-icon") as SVGElement;
|
|
const checkCssIcon = document.getElementById("gg-check-css-icon") as SVGElement;
|
|
const colorStopsEl = document.getElementById("gg-color-stops") as HTMLDivElement;
|
|
const generateBtn = document.getElementById("gg-btn") as HTMLButtonElement;
|
|
|
|
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
|
const checkIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-accent"><path d="M20 6 9 17l-5-5"/></svg>`;
|
|
|
|
function randomInt(min: number, max: number): number {
|
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
}
|
|
|
|
function hslToHex(h: number, s: number, l: number): string {
|
|
s /= 100;
|
|
l /= 100;
|
|
const k = (n: number) => (n + h / 30) % 12;
|
|
const a = s * Math.min(l, 1 - l);
|
|
const f = (n: number) => {
|
|
const color = l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
return Math.round(color * 255)
|
|
.toString(16)
|
|
.padStart(2, "0");
|
|
};
|
|
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
|
|
}
|
|
|
|
function generateHarmoniousColors(count: number): string[] {
|
|
const baseH = randomInt(0, 359);
|
|
const baseS = randomInt(50, 90);
|
|
const baseL = randomInt(35, 65);
|
|
|
|
if (count === 1) {
|
|
return [hslToHex(baseH, baseS, baseL)];
|
|
}
|
|
|
|
const colors: string[] = [];
|
|
// Spread hues analogously (30-60 deg apart)
|
|
const hueSpread = randomInt(30, 60);
|
|
const totalSpread = (count - 1) * hueSpread;
|
|
const startH = (baseH - totalSpread / 2 + 360) % 360;
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const h = Math.round((startH + i * hueSpread + 360) % 360);
|
|
// Vary saturation and lightness slightly for visual interest
|
|
const s = Math.min(100, Math.max(40, baseS + randomInt(-10, 10)));
|
|
const l = Math.min(80, Math.max(25, baseL + randomInt(-10, 10)));
|
|
colors.push(hslToHex(h, s, l));
|
|
}
|
|
|
|
return colors;
|
|
}
|
|
|
|
function buildGradientCSS(type: string, colors: string[]): string {
|
|
const count = colors.length;
|
|
|
|
if (type === "linear") {
|
|
const angle = randomInt(0, 360);
|
|
if (count === 2) {
|
|
return `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
|
|
}
|
|
const stops = colors.map((c, i) => {
|
|
const pct = Math.round((i / (count - 1)) * 100);
|
|
return `${c} ${pct}%`;
|
|
}).join(", ");
|
|
return `linear-gradient(${angle}deg, ${stops})`;
|
|
}
|
|
|
|
if (type === "radial") {
|
|
const cx = randomInt(20, 80);
|
|
const cy = randomInt(20, 80);
|
|
if (count === 2) {
|
|
return `radial-gradient(circle at ${cx}% ${cy}%, ${colors[0]} 0%, ${colors[1]} 100%)`;
|
|
}
|
|
const stops = colors.map((c, i) => {
|
|
const pct = Math.round((i / (count - 1)) * 100);
|
|
return `${c} ${pct}%`;
|
|
}).join(", ");
|
|
return `radial-gradient(circle at ${cx}% ${cy}%, ${stops})`;
|
|
}
|
|
|
|
// conic
|
|
if (count === 2) {
|
|
return `conic-gradient(from 0deg, ${colors[0]}, ${colors[1]}, ${colors[0]})`;
|
|
}
|
|
const stops = colors.join(", ");
|
|
return `conic-gradient(from 0deg, ${stops})`;
|
|
}
|
|
|
|
let currentCSS = "";
|
|
let cssCopyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const copyTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
|
|
|
function renderColorStops(colors: string[]) {
|
|
colorStopsEl.innerHTML = "";
|
|
copyTimers.clear();
|
|
|
|
colors.forEach((hex, index) => {
|
|
const chip = document.createElement("button");
|
|
chip.type = "button";
|
|
chip.className =
|
|
"group flex items-center gap-2 px-3 py-2 bg-zinc-950 border border-zinc-800 rounded-xl hover:border-zinc-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-all duration-100 cursor-pointer";
|
|
chip.setAttribute("aria-label", isRu ? `Скопировать ${hex}` : `Copy ${hex}`);
|
|
|
|
const swatch = document.createElement("span");
|
|
swatch.className = "w-5 h-5 rounded-full border border-zinc-700 shrink-0";
|
|
swatch.style.backgroundColor = hex;
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "font-mono text-sm text-zinc-300 tabular-nums select-all group-hover:text-zinc-100 transition-colors";
|
|
label.textContent = hex;
|
|
|
|
const iconWrap = document.createElement("span");
|
|
iconWrap.className = "text-zinc-600 group-hover:text-zinc-400 transition-colors ml-1";
|
|
iconWrap.innerHTML = copyIconSvg;
|
|
|
|
chip.appendChild(swatch);
|
|
chip.appendChild(label);
|
|
chip.appendChild(iconWrap);
|
|
|
|
chip.addEventListener("click", async () => {
|
|
await navigator.clipboard.writeText(hex);
|
|
iconWrap.innerHTML = checkIconSvg;
|
|
label.classList.add("text-accent");
|
|
const existing = copyTimers.get(index);
|
|
if (existing) clearTimeout(existing);
|
|
copyTimers.set(
|
|
index,
|
|
setTimeout(() => {
|
|
iconWrap.innerHTML = copyIconSvg;
|
|
label.classList.remove("text-accent");
|
|
copyTimers.delete(index);
|
|
}, 1500),
|
|
);
|
|
});
|
|
|
|
colorStopsEl.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
function render(css: string, colors: string[]) {
|
|
currentCSS = css;
|
|
previewEl.style.background = css;
|
|
cssCodeEl.textContent = `background: ${css};`;
|
|
renderColorStops(colors);
|
|
resultEl.style.opacity = "1";
|
|
}
|
|
|
|
copyCssBtn.addEventListener("click", async () => {
|
|
if (!currentCSS) return;
|
|
const text = `background: ${currentCSS};`;
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
copyCssIcon.classList.add("hidden");
|
|
checkCssIcon.classList.remove("hidden");
|
|
copyCssLabel.textContent = isRu ? "Скопировано!" : "Copied!";
|
|
if (cssCopyTimer) clearTimeout(cssCopyTimer);
|
|
cssCopyTimer = setTimeout(() => {
|
|
checkCssIcon.classList.add("hidden");
|
|
copyCssIcon.classList.remove("hidden");
|
|
copyCssLabel.textContent = isRu ? "Копировать" : "Copy";
|
|
}, 1500);
|
|
});
|
|
|
|
function generate() {
|
|
const type = typeSelect.value;
|
|
const count = parseInt(stopsSelect.value, 10);
|
|
const colors = generateHarmoniousColors(count);
|
|
const css = buildGradientCSS(type, colors);
|
|
render(css, colors);
|
|
}
|
|
|
|
generateBtn.addEventListener("click", generate);
|
|
|
|
// Generate on load
|
|
generate();
|
|
</script>
|