Files
Randify.pro/src/components/generators/TimeGenerator.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

436 lines
16 KiB
Plaintext

---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
---
<div id="time-generator" class="mt-8" data-ru={isRu ? "1" : "0"}>
<!-- Controls -->
<div class="rounded-xl bg-zinc-900/50 border border-zinc-800/60 p-5 space-y-4">
<!-- Time range -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="tg-from" class="block text-sm font-medium text-zinc-400 mb-1">{T.from}</label>
<input
id="tg-from"
type="time"
value="00:00"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
<div class="flex-1">
<label for="tg-to" class="block text-sm font-medium text-zinc-400 mb-1">{T.to}</label>
<input
id="tg-to"
type="time"
value="23:59"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors"
/>
</div>
</div>
<!-- Interval and Format -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="tg-interval" class="block text-sm font-medium text-zinc-400 mb-1">{isRu ? "Интервал" : "Interval"}</label>
<select
id="tg-interval"
class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-base focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition-colors appearance-none cursor-pointer"
>
<option value="1">{isRu ? "Каждую минуту" : "Every minute"}</option>
<option value="5">{isRu ? "Каждые 5 минут" : "Every 5 minutes"}</option>
<option value="15">{isRu ? "Каждые 15 минут" : "Every 15 minutes"}</option>
<option value="30">{isRu ? "Каждые 30 минут" : "Every 30 minutes"}</option>
<option value="60">{isRu ? "Каждый час" : "Hourly"}</option>
</select>
</div>
<div class="flex-1">
<label class="block text-sm font-medium text-zinc-400 mb-1">{isRu ? "Формат" : "Format"}</label>
<div class="flex bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden">
<button
id="tg-format-24"
type="button"
class="flex-1 px-3 py-2 text-sm font-medium bg-accent text-white transition-colors cursor-pointer"
>
24h
</button>
<button
id="tg-format-12"
type="button"
class="flex-1 px-3 py-2 text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors cursor-pointer"
>
12h
</button>
</div>
</div>
</div>
</div>
<!-- Error -->
<p
id="tg-error"
role="alert"
aria-live="polite"
class="mt-2 text-sm text-red-600 hidden"
></p>
<!-- Result / Clock Display -->
<div class="my-6 rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6 sm:p-8">
<div class="flex flex-col items-center gap-4">
<!-- SVG Analog Clock -->
<div class="relative">
<svg id="tg-clock" width="220" height="220" viewBox="0 0 220 220" class="drop-shadow-lg">
<!-- Clock face -->
<circle cx="110" cy="110" r="100" fill="transparent" stroke="#3f3f46" stroke-width="2" />
<circle cx="110" cy="110" r="96" fill="transparent" stroke="#27272a" stroke-width="1" />
<!-- Hour markers (generated by JS) -->
<g id="tg-clock-markers"></g>
<!-- Hour hand -->
<line
id="tg-hand-hour"
x1="110" y1="110" x2="110" y2="55"
stroke="#e4e4e7" stroke-width="4" stroke-linecap="round"
/>
<!-- Minute hand -->
<line
id="tg-hand-minute"
x1="110" y1="110" x2="110" y2="30"
stroke="#d4d4d8" stroke-width="2.5" stroke-linecap="round"
/>
<!-- Second hand (accent) -->
<line
id="tg-hand-second"
x1="110" y1="110" x2="110" y2="25"
stroke="#f97316" stroke-width="1.5" stroke-linecap="round"
/>
<!-- Center dot -->
<circle cx="110" cy="110" r="5" fill="#f97316" />
<circle cx="110" cy="110" r="2.5" fill="#18181b" />
</svg>
</div>
<!-- Digital time + description -->
<div class="flex flex-col items-center gap-2 text-center">
<button
id="tg-copy-btn"
type="button"
class="group relative invisible cursor-copy rounded-xl px-3 py-1 -mx-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
aria-live="polite"
>
<span
id="tg-result"
class="text-3xl sm:text-4xl font-semibold text-zinc-100 select-none group-hover:text-zinc-300 text-center block"
style="transition: transform 0.12s cubic-bezier(0.34,1.56,0.64,1), opacity 0.08s ease, color 0.15s ease;"
></span>
<!-- Copy icon -->
<span
id="tg-copy-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 text-zinc-500"
aria-hidden="true"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
</span>
<!-- Check icon -->
<span
id="tg-check-icon"
class="absolute -right-7 top-1/2 -translate-y-1/2 hidden text-accent"
aria-hidden="true"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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"></path></svg>
</span>
</button>
<span
id="tg-copied-label"
class="text-xs font-medium text-accent opacity-0 transition-opacity duration-200"
aria-live="polite"
aria-atomic="true"
>{T.copied}</span>
<!-- Time description -->
<span id="tg-time-desc" class="text-sm text-zinc-500 font-medium"></span>
<!-- Alternate format (12h/24h equivalent) -->
<span id="tg-alt-format" class="text-xs text-zinc-600"></span>
</div>
</div>
</div>
<!-- Generate Button -->
<div class="flex justify-center">
<button
id="tg-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl 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 shadow-lg shadow-accent/25"
>
{T.generate}
</button>
</div>
</div>
<script>
import { CopyFeedback } from "@/lib/client/clipboard";
import { createErrorDisplay } from "@/lib/client/validation";
import { popElement } from "@/lib/client/animations";
const isRu = document.documentElement.lang === "ru";
const ERR_FROM_BEFORE_TO = isRu
? "«От» должно быть раньше «До»."
: '"From" must be before "To".';
const ERR_SAME_TIME = isRu
? "Время «От» и «До» не могут совпадать."
: '"From" and "To" times cannot be the same.';
// Time descriptions by language
const timeDescs: Record<number, string> = isRu ? {
0: "Ночь",
5: "Раннее утро",
9: "Утро",
12: "Полдень",
14: "День",
17: "Вечер",
20: "Поздний вечер",
22: "Ночь",
} : {
0: "Night",
5: "Early morning",
9: "Morning",
12: "Midday",
14: "Afternoon",
17: "Evening",
20: "Late evening",
22: "Night",
};
function getTimeDescription(hour: number): string {
if (hour >= 5 && hour < 9) return timeDescs[5];
if (hour >= 9 && hour < 12) return timeDescs[9];
if (hour >= 12 && hour < 14) return timeDescs[12];
if (hour >= 14 && hour < 17) return timeDescs[14];
if (hour >= 17 && hour < 20) return timeDescs[17];
if (hour >= 20 && hour < 22) return timeDescs[20];
return timeDescs[0]; // 22-04 = Night
}
// DOM refs
const fromInput = document.getElementById("tg-from") as HTMLInputElement;
const toInput = document.getElementById("tg-to") as HTMLInputElement;
const intervalSelect = document.getElementById("tg-interval") as HTMLSelectElement;
const btn = document.getElementById("tg-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("tg-copy-btn") as HTMLButtonElement;
const resultEl = document.getElementById("tg-result") as HTMLSpanElement;
const errorEl = document.getElementById("tg-error") as HTMLParagraphElement;
const copyIcon = document.getElementById("tg-copy-icon") as HTMLSpanElement;
const checkIcon = document.getElementById("tg-check-icon") as HTMLSpanElement;
const copiedLabel = document.getElementById("tg-copied-label") as HTMLSpanElement;
const timeDescEl = document.getElementById("tg-time-desc") as HTMLSpanElement;
const altFormatEl = document.getElementById("tg-alt-format") as HTMLSpanElement;
const format24Btn = document.getElementById("tg-format-24") as HTMLButtonElement;
const format12Btn = document.getElementById("tg-format-12") as HTMLButtonElement;
const clockMarkersGroup = document.getElementById("tg-clock-markers") as SVGGElement;
const handHour = document.getElementById("tg-hand-hour") as SVGLineElement;
const handMinute = document.getElementById("tg-hand-minute") as SVGLineElement;
const handSecond = document.getElementById("tg-hand-second") as SVGLineElement;
const errors = createErrorDisplay(errorEl);
const clipboard = new CopyFeedback(copyIcon, checkIcon);
let is24Hour = true;
let currentHour = 0;
let currentMinute = 0;
let currentSecond = 0;
// Generate clock hour markers
function generateClockMarkers() {
for (let i = 0; i < 12; i++) {
const angle = (i * 30) * Math.PI / 180;
const isCardinal = i % 3 === 0;
const innerR = isCardinal ? 82 : 88;
const outerR = 96;
const x1 = 110 + innerR * Math.sin(angle);
const y1 = 110 - innerR * Math.cos(angle);
const x2 = 110 + outerR * Math.sin(angle);
const y2 = 110 - outerR * Math.cos(angle);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", String(x1));
line.setAttribute("y1", String(y1));
line.setAttribute("x2", String(x2));
line.setAttribute("y2", String(y2));
line.setAttribute("stroke", isCardinal ? "#a1a1aa" : "#52525b");
line.setAttribute("stroke-width", isCardinal ? "2.5" : "1.5");
line.setAttribute("stroke-linecap", "round");
clockMarkersGroup.appendChild(line);
}
}
generateClockMarkers();
function parseTimeInput(value: string): { h: number; m: number } {
const [h, m] = value.split(":").map(Number);
return { h, m };
}
function timeToMinutes(h: number, m: number): number {
return h * 60 + m;
}
function minutesToTime(totalMinutes: number): { h: number; m: number } {
const h = Math.floor(totalMinutes / 60) % 24;
const m = totalMinutes % 60;
return { h, m };
}
function formatTime12h(h: number, m: number): string {
const period = h >= 12 ? (isRu ? "ПМ" : "PM") : (isRu ? "АМ" : "AM");
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${String(h12).padStart(2, "0")}:${String(m).padStart(2, "0")} ${period}`;
}
function formatTime24h(h: number, m: number): string {
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function updateClockFace(hour: number, minute: number, second: number) {
// Hour hand: 30 degrees per hour + 0.5 per minute
const hourAngle = (hour % 12) * 30 + minute * 0.5;
// Minute hand: 6 degrees per minute
const minuteAngle = minute * 6;
// Second hand: 6 degrees per second
const secondAngle = second * 6;
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg - 90) * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
const hourEnd = polarToCartesian(110, 110, 55, hourAngle);
handHour.setAttribute("x2", String(hourEnd.x));
handHour.setAttribute("y2", String(hourEnd.y));
const minuteEnd = polarToCartesian(110, 110, 78, minuteAngle);
handMinute.setAttribute("x2", String(minuteEnd.x));
handMinute.setAttribute("y2", String(minuteEnd.y));
const secondEnd = polarToCartesian(110, 110, 82, secondAngle);
handSecond.setAttribute("x2", String(secondEnd.x));
handSecond.setAttribute("y2", String(secondEnd.y));
}
function displayResult(h: number, m: number, s: number) {
currentHour = h;
currentMinute = m;
currentSecond = s;
// Main display
if (is24Hour) {
resultEl.textContent = formatTime24h(h, m);
altFormatEl.textContent = formatTime12h(h, m);
} else {
resultEl.textContent = formatTime12h(h, m);
altFormatEl.textContent = formatTime24h(h, m);
}
// Description
timeDescEl.textContent = getTimeDescription(h);
// Update clock
updateClockFace(h, m, s);
copyBtn.classList.remove("invisible");
popElement(resultEl);
}
function generate() {
const fromVal = fromInput.value;
const toVal = toInput.value;
if (!fromVal || !toVal) {
errors.show(isRu ? "Укажите оба времени." : "Please enter both times.");
return;
}
const fromParsed = parseTimeInput(fromVal);
const toParsed = parseTimeInput(toVal);
const fromMins = timeToMinutes(fromParsed.h, fromParsed.m);
const toMins = timeToMinutes(toParsed.h, toParsed.m);
if (fromMins >= toMins) {
errors.show(fromMins === toMins ? ERR_SAME_TIME : ERR_FROM_BEFORE_TO);
return;
}
errors.clear();
const interval = parseInt(intervalSelect.value, 10);
// Generate random time within range, respecting interval
const rangeMinutes = toMins - fromMins;
const maxSteps = Math.floor(rangeMinutes / interval);
const randomStep = Math.floor(Math.random() * (maxSteps + 1));
const randomTotalMinutes = fromMins + randomStep * interval;
const { h, m } = minutesToTime(randomTotalMinutes);
const s = Math.floor(Math.random() * 60);
displayResult(h, m, s);
}
async function copyTime() {
const value = resultEl.textContent?.trim();
if (!value) return;
await navigator.clipboard.writeText(value);
clipboard.showCopied();
copiedLabel.style.opacity = "1";
setTimeout(() => {
copiedLabel.style.opacity = "0";
}, 1500);
}
// Format toggle
function setFormat24() {
is24Hour = true;
format24Btn.classList.add("bg-accent", "text-white");
format24Btn.classList.remove("text-zinc-400", "hover:text-zinc-200");
format12Btn.classList.remove("bg-accent", "text-white");
format12Btn.classList.add("text-zinc-400", "hover:text-zinc-200");
// Refresh display
if (resultEl.textContent) {
displayResult(currentHour, currentMinute, currentSecond);
}
}
function setFormat12() {
is24Hour = false;
format12Btn.classList.add("bg-accent", "text-white");
format12Btn.classList.remove("text-zinc-400", "hover:text-zinc-200");
format24Btn.classList.remove("bg-accent", "text-white");
format24Btn.classList.add("text-zinc-400", "hover:text-zinc-200");
// Refresh display
if (resultEl.textContent) {
displayResult(currentHour, currentMinute, currentSecond);
}
}
// Events
btn.addEventListener("click", generate);
copyBtn.addEventListener("click", copyTime);
format24Btn.addEventListener("click", setFormat24);
format12Btn.addEventListener("click", setFormat12);
[fromInput, toInput].forEach((input) => {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
});
intervalSelect.addEventListener("keydown", (e) => {
if (e.key === "Enter") generate();
});
// Generate on load
generate();
</script>