Files
Randify.pro/src/components/generators/GradientGenerator.astro
T
emil 8f11c5af43 feat: add favorites panel to meal generator, remove redundant Another one button
- 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
2026-05-13 00:25:09 +03:00

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>