fix(admin-ui): v1.1.1 patch — version routing + guest mode + timeline UX
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DatePicker } from '@nstart/ui'
|
||||
import { ClockCounterClockwiseIcon, XIcon } from '@phosphor-icons/react'
|
||||
|
||||
/**
|
||||
* Picker для time-travel: «активная версия на момент X».
|
||||
*
|
||||
* <p>До v1.1.1 был голый {@code <input type="datetime-local">} — pre-2010
|
||||
* native control с непредсказуемым стилем по браузерам и без TZ awareness.
|
||||
* Здесь:
|
||||
* <ul>
|
||||
* <li>{@code DatePicker} из {@code @nstart/ui} (RU locale, beautiful native dropdown).</li>
|
||||
* <li>Time entry — двух-полевой HH:MM (number inputs).</li>
|
||||
* <li>Quick-preset chips: «Сейчас» / «−1ч» / «Вчера» / «Неделя» / «Месяц» / «Год».</li>
|
||||
* <li>Relative-time бейдж рядом со значением (через {@code Intl.RelativeTimeFormat}).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>State модель: контролируемый компонент, parent держит {@code at: ISO string |
|
||||
* undefined}. Undefined = «сейчас» (live). Любое preset / picker change → onChange
|
||||
* с новым ISO. Сброс через {@code onClear} (parent очищает {@code ?at} в URL).
|
||||
*/
|
||||
export function TimeTravelPicker({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
onClose,
|
||||
}: {
|
||||
value: string | undefined
|
||||
onChange: (iso: string) => void
|
||||
onClear: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const lang = (i18n.language || 'ru').split('-')[0]
|
||||
|
||||
// Текущее значение date-only (для DatePicker) и time HH:MM (для number inputs).
|
||||
const valueDate = useMemo(() => {
|
||||
if (!value) return undefined
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? undefined : d
|
||||
}, [value])
|
||||
|
||||
const hh = valueDate ? valueDate.getHours() : new Date().getHours()
|
||||
const mm = valueDate ? valueDate.getMinutes() : new Date().getMinutes()
|
||||
|
||||
// Combine date + HH:MM в ISO с локальным TZ-offset (preserve wall-clock).
|
||||
const emitWithTime = (date: Date, hours: number, minutes: number) => {
|
||||
const d = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
hours,
|
||||
minutes,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
onChange(d.toISOString())
|
||||
}
|
||||
|
||||
const handleDateChange = (d: Date | null) => {
|
||||
if (!d) {
|
||||
onClear()
|
||||
return
|
||||
}
|
||||
emitWithTime(d, hh, mm)
|
||||
}
|
||||
|
||||
const handleHoursChange = (h: number) => {
|
||||
if (!valueDate) {
|
||||
// First time-edit без выбранной даты → принимаем «сегодня».
|
||||
emitWithTime(new Date(), Math.max(0, Math.min(23, h)), mm)
|
||||
return
|
||||
}
|
||||
emitWithTime(valueDate, Math.max(0, Math.min(23, h)), mm)
|
||||
}
|
||||
|
||||
const handleMinutesChange = (m: number) => {
|
||||
if (!valueDate) {
|
||||
emitWithTime(new Date(), hh, Math.max(0, Math.min(59, m)))
|
||||
return
|
||||
}
|
||||
emitWithTime(valueDate, hh, Math.max(0, Math.min(59, m)))
|
||||
}
|
||||
|
||||
const applyPreset = (deltaMs: number) => {
|
||||
const target = new Date(Date.now() + deltaMs)
|
||||
onChange(target.toISOString())
|
||||
}
|
||||
|
||||
// Relative-time строка: «3 ч назад», «через 2 дня». Intl.RelativeTimeFormat
|
||||
// нативный, без depends, locale-aware.
|
||||
const relativeLabel = useMemo(() => {
|
||||
if (!valueDate) return null
|
||||
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' })
|
||||
const diffMs = valueDate.getTime() - Date.now()
|
||||
const absH = Math.abs(diffMs) / 3_600_000
|
||||
const absD = absH / 24
|
||||
if (absH < 1) {
|
||||
return rtf.format(Math.round(diffMs / 60_000), 'minute')
|
||||
}
|
||||
if (absH < 48) {
|
||||
return rtf.format(Math.round(diffMs / 3_600_000), 'hour')
|
||||
}
|
||||
if (absD < 60) {
|
||||
return rtf.format(Math.round(diffMs / 86_400_000), 'day')
|
||||
}
|
||||
return rtf.format(Math.round(diffMs / (86_400_000 * 30)), 'month')
|
||||
}, [valueDate, lang])
|
||||
|
||||
const presets: ReadonlyArray<{ key: string; label: string; deltaMs: number }> = [
|
||||
{ key: 'now', label: t('timeTravel.preset.now'), deltaMs: 0 },
|
||||
{ key: '-1h', label: t('timeTravel.preset.hourAgo'), deltaMs: -3_600_000 },
|
||||
{ key: '-1d', label: t('timeTravel.preset.dayAgo'), deltaMs: -86_400_000 },
|
||||
{ key: '-7d', label: t('timeTravel.preset.weekAgo'), deltaMs: -7 * 86_400_000 },
|
||||
{ key: '-30d', label: t('timeTravel.preset.monthAgo'), deltaMs: -30 * 86_400_000 },
|
||||
{ key: '-1y', label: t('timeTravel.preset.yearAgo'), deltaMs: -365 * 86_400_000 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 py-3 rounded-md border border-orbit/40 bg-orbit/4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-orbit" />
|
||||
<span className="text-2xs font-secondary uppercase tracking-label text-carbon/70">
|
||||
{t('timeTravel.label')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto text-2xs text-carbon/60 hover:text-carbon hover:underline inline-flex items-center gap-1"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon weight="bold" size={12} />
|
||||
{t('timeTravel.close')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<DatePicker
|
||||
label={t('timeTravel.date')}
|
||||
value={valueDate}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-2xs uppercase tracking-label text-carbon/70 font-secondary">
|
||||
{t('timeTravel.time')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={String(hh).padStart(2, '0')}
|
||||
onChange={(e) => handleHoursChange(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-12 px-2 py-1 rounded-sm border border-regolith bg-white text-sm font-mono text-center"
|
||||
aria-label={t('timeTravel.hours')}
|
||||
/>
|
||||
<span className="text-carbon/50 font-mono">:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(mm).padStart(2, '0')}
|
||||
onChange={(e) => handleMinutesChange(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-12 px-2 py-1 rounded-sm border border-regolith bg-white text-sm font-mono text-center"
|
||||
aria-label={t('timeTravel.minutes')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-2xs uppercase tracking-label text-carbon/60 font-secondary mr-1">
|
||||
{t('timeTravel.quickPresets')}
|
||||
</span>
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => applyPreset(p.deltaMs)}
|
||||
className="px-2.5 py-1 rounded-full border border-regolith hover:border-ultramarain hover:bg-ultramarain/4 text-2xs font-mono uppercase tracking-label text-carbon transition-colors"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{valueDate && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-orbit/20">
|
||||
<ClockCounterClockwiseIcon weight="regular" size={14} className="text-orbit shrink-0" />
|
||||
<span className="text-xs font-mono text-carbon/80">
|
||||
{valueDate.toLocaleString(lang === 'en' ? 'en-US' : 'ru-RU', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
</span>
|
||||
{relativeLabel && (
|
||||
<span className="text-2xs text-carbon/60 font-secondary lowercase">
|
||||
({relativeLabel})
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto text-2xs text-ultramarain hover:underline"
|
||||
onClick={onClear}
|
||||
>
|
||||
{t('timeTravel.clear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user