- 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
436 lines
16 KiB
Plaintext
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>
|