feat(editor): ExportModal + TimeTravelModal + InfoPanel merge + fixes
UI redesign matching prototype (Ordinis (3) dump): - ExportModal: format (CSV/JSON/Excel/SQL) + scope (all/filtered/selected) + column toggles + encoding/delimiter + preview filename per redesign line 1059-1092. CSV uses backend mutation, JSON client-side from filtered rows, Excel/SQL disabled (backend pending). - TimeTravelModal: full-page compare СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ + quick presets (сейчас/неделя/месяц/год/при создании) + step toggle версия/день + slider + tabs (Что изменилось / Записи на момент / Структура и поля) + "Открыть как readonly" CTA. Replaces inline TimeTravelPicker. - InfoPanel ← используют merge: rich rows с field path + active records count + onClose policy chip (BLOCK/WARN/CASCADE). Inline DictionaryDependentsPanel above records table removed (one source of truth). - Sidebar logo: match redesign/ui-kit.html .brand SVG (outer ring + inner faded ring + orbit dot offset right cx=22.5). ORDINIS Tektur tracking 0.22em + MDM smaller mute 0.32em. - Bg neutralized: #fbf8ee Earth cream → #faf9f5 warm neutral default (per user preference, Earth cream still described in comment как opt-in). - WorkflowBanner: Case 1 Live (approvalRequired=false) returns null — permanent "Опубликовано" banner = noise, status already visible в InfoPanel scope/version/updatedAt. Banner показывается только когда есть actionable state. Critical bug fixes: - fix(CascadeConfirmDialog): infinite render loop "Maximum update depth exceeded". useEffect had cascadeMut (TanStack Query mutation wrapper) в deps — wrapper identity changes every render → effect fires → reset() → store update → render → loop. Extract stable `reset` reference перед useEffect. Симптомы у user: создание/редактирование записи не работали. - fix(semantic-release): add missing peer dep conventional-changelog-conventionalcommits (9.3.1). semantic-release 22+ больше не bundles preset. Без неё release job падал с "Cannot find module 'conventional-changelog-conventionalcommits'".
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
|
||||
import { Dialog, DialogContent } from '@/ui'
|
||||
import type { DictionaryDetail } from '@/api/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* TimeTravelModal — fullscreen-y modal per redesign prototype.
|
||||
*
|
||||
* <p>Layout:
|
||||
* <ul>
|
||||
* <li><b>Header</b>: clock icon + TIME-TRAVEL · {DICT} cap + h1 title + ×</li>
|
||||
* <li><b>Compare panels</b>: СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ side-by-side.
|
||||
* Right panel highlighted (`tint`) когда target != current.</li>
|
||||
* <li><b>Quick presets</b>: сейчас / неделя / месяц / год / при создании</li>
|
||||
* <li><b>Step toggle</b>: версия | день + ‹ › nav arrows</li>
|
||||
* <li><b>Slider</b>: HTML range + overlay version markers (top) + date
|
||||
* markers (bottom). Cursor — dark circle.</li>
|
||||
* <li><b>Tabs</b>: Что изменилось / Записи на момент / Структура и поля.
|
||||
* Backend diff endpoints отсутствуют — показываем empty/placeholder
|
||||
* состояния с подсказкой backend pending.</li>
|
||||
* <li><b>Footer</b>: disclaimer + Закрыть / "Открыть v{X} как readonly" CTA</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Backend coverage:</b> findActiveAt(at) уже работает (read-api). Нет
|
||||
* snapshots endpoint, нет diff. Поэтому version labels на slider — выводятся
|
||||
* из schemaVersion текущей версии (single point), а исторические marks из
|
||||
* record validFrom timestamps. "Открыть как readonly" просто применяет
|
||||
* ?at=ISO в URL — route обрабатывает.
|
||||
*/
|
||||
|
||||
export type TimeTravelModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
detail: DictionaryDetail
|
||||
/** Currently applied at-ISO (если активен readonly snapshot). */
|
||||
value: string | undefined
|
||||
/** Apply the picked moment: opens dict as readonly (route uses ?at=). */
|
||||
onApply: (iso: string) => void
|
||||
/** Clear at — return к live view. */
|
||||
onClear: () => void
|
||||
/** Unique record validFrom timestamps (ms) — для slider tick marks. */
|
||||
marks: number[]
|
||||
/** Total records на текущий момент — для tab counter. */
|
||||
totalRecords: number
|
||||
}
|
||||
|
||||
type StepMode = 'version' | 'day'
|
||||
|
||||
export function TimeTravelModal({
|
||||
open,
|
||||
onClose,
|
||||
detail,
|
||||
value,
|
||||
onApply,
|
||||
onClear,
|
||||
marks,
|
||||
totalRecords,
|
||||
}: TimeTravelModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const now = Date.now()
|
||||
const valueMs = useMemo(() => {
|
||||
if (!value) return now
|
||||
const ms = Date.parse(value)
|
||||
return Number.isNaN(ms) ? now : ms
|
||||
}, [value, now])
|
||||
|
||||
const [picked, setPicked] = useState<number>(valueMs)
|
||||
const [step, setStep] = useState<StepMode>('version')
|
||||
const [tab, setTab] = useState<'changes' | 'records' | 'schema'>('changes')
|
||||
|
||||
// Range: от min(marks, createdAt) до now+padding.
|
||||
const createdMs = Date.parse(detail.createdAt)
|
||||
const range = useMemo(() => {
|
||||
const minMark = marks.length > 0 ? Math.min(...marks, createdMs) : createdMs - 86_400_000
|
||||
const span = now - minMark
|
||||
const pad = Math.max(span * 0.04, 86_400_000)
|
||||
return { min: minMark - pad, max: now + pad }
|
||||
}, [marks, createdMs, now])
|
||||
|
||||
// Sync picked when value prop changes while modal open.
|
||||
// (Don't useEffect — пока explicitly value control.)
|
||||
|
||||
const pickedDate = new Date(picked)
|
||||
const isAtNow = Math.abs(picked - now) < 60_000 // < 1 min ≈ now
|
||||
const targetVersion = detail.schemaVersion // backend doesn't expose snapshots
|
||||
const targetDiffers = !isAtNow
|
||||
|
||||
const handlePreset = (deltaMs: number | 'create') => {
|
||||
if (deltaMs === 'create') {
|
||||
setPicked(createdMs)
|
||||
} else if (deltaMs === 0) {
|
||||
setPicked(now)
|
||||
} else {
|
||||
setPicked(now + deltaMs)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepNav = (direction: -1 | 1) => {
|
||||
if (step === 'day') {
|
||||
setPicked((p) => Math.min(range.max, Math.max(range.min, p + direction * 86_400_000)))
|
||||
} else {
|
||||
// Version step: jump to nearest mark
|
||||
const sorted = [...marks].sort((a, b) => a - b)
|
||||
if (sorted.length === 0) return
|
||||
if (direction === -1) {
|
||||
const prev = [...sorted].reverse().find((m) => m < picked - 1000)
|
||||
if (prev !== undefined) setPicked(prev)
|
||||
} else {
|
||||
const next = sorted.find((m) => m > picked + 1000)
|
||||
if (next !== undefined) setPicked(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
if (isAtNow) {
|
||||
onClear()
|
||||
} else {
|
||||
onApply(new Date(picked).toISOString())
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent size="full" className="!p-0 gap-0 max-h-[95vh]" hideClose>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 px-6 py-4 border-b border-line">
|
||||
<div className="size-8 rounded-md bg-surface-2 inline-flex items-center justify-center shrink-0">
|
||||
<ClockCounterClockwiseIcon weight="regular" size={18} className="text-ink-2" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-cap text-mute tracking-[0.18em] uppercase">
|
||||
Time-travel
|
||||
<span className="mx-2 text-line">·</span>
|
||||
<span className="text-ink">{detail.name.toUpperCase()}</span>
|
||||
</div>
|
||||
<h2 className="text-title-md text-ink font-semibold mt-0.5">
|
||||
{t('timeTravel.title', { defaultValue: 'Состояние справочника на момент времени' })}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close', { defaultValue: 'Закрыть' })}
|
||||
className="rounded-sm p-1 text-mute hover:text-ink hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
<XIcon weight="bold" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compare panels */}
|
||||
<div className="grid grid-cols-2 border-b border-line">
|
||||
<ComparePanel
|
||||
label={t('timeTravel.compare.now', { defaultValue: 'Сейчас' })}
|
||||
version={detail.schemaVersion}
|
||||
ms={now}
|
||||
records={totalRecords}
|
||||
author={(detail as DictionaryDetail & { updatedBy?: string }).updatedBy}
|
||||
/>
|
||||
<ComparePanel
|
||||
label={
|
||||
<>
|
||||
{t('timeTravel.compare.point', { defaultValue: 'Точка во времени' })}
|
||||
{targetDiffers && (
|
||||
<span className="ml-2 text-pink text-cap tracking-wider">
|
||||
· {t('timeTravel.compare.diff', { defaultValue: 'отличается' })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
version={targetVersion}
|
||||
ms={picked}
|
||||
records={totalRecords /* backend ?at= даст реальный count при заходе */}
|
||||
author={(detail as DictionaryDetail & { updatedBy?: string }).updatedBy}
|
||||
note={isAtNow ? '«current»' : undefined}
|
||||
highlight={targetDiffers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick presets + step toggle */}
|
||||
<div className="px-6 py-3 border-b border-line-2 flex items-center gap-3 flex-wrap">
|
||||
<div className="text-cap text-mute tracking-[0.16em] uppercase">
|
||||
{t('timeTravel.quick', { defaultValue: 'Быстро' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Preset label="сейчас" onClick={() => handlePreset(0)} />
|
||||
<Preset label="неделя назад" onClick={() => handlePreset(-7 * 86_400_000)} />
|
||||
<Preset label="месяц назад" onClick={() => handlePreset(-30 * 86_400_000)} />
|
||||
<Preset label="год назад" onClick={() => handlePreset(-365 * 86_400_000)} />
|
||||
<Preset label="при создании" onClick={() => handlePreset('create')} />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="text-cap text-mute tracking-[0.16em] uppercase mr-1">
|
||||
{t('timeTravel.step', { defaultValue: 'Шаг' })}
|
||||
</div>
|
||||
<div className="inline-flex rounded-md border border-line bg-surface overflow-hidden">
|
||||
<StepBtn active={step === 'version'} onClick={() => setStep('version')} label="версия" />
|
||||
<StepBtn active={step === 'day'} onClick={() => setStep('day')} label="день" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStepNav(-1)}
|
||||
className="size-8 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
|
||||
aria-label="prev"
|
||||
>
|
||||
<CaretLeftIcon weight="bold" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStepNav(1)}
|
||||
className="size-8 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
|
||||
aria-label="next"
|
||||
>
|
||||
<CaretRightIcon weight="bold" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="px-6 pt-5 pb-3 border-b border-line bg-surface-2/40">
|
||||
<TimelineSlider
|
||||
range={range}
|
||||
marks={marks}
|
||||
value={picked}
|
||||
onChange={setPicked}
|
||||
currentVersion={detail.schemaVersion}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="px-6 pt-3 border-b border-line">
|
||||
<div className="flex items-end gap-6 -mb-px">
|
||||
<TabBtn
|
||||
active={tab === 'changes'}
|
||||
onClick={() => setTab('changes')}
|
||||
label={t('timeTravel.tabs.changes', { defaultValue: 'Что изменилось' })}
|
||||
count={0}
|
||||
/>
|
||||
<TabBtn
|
||||
active={tab === 'records'}
|
||||
onClick={() => setTab('records')}
|
||||
label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })}
|
||||
count={totalRecords}
|
||||
/>
|
||||
<TabBtn
|
||||
active={tab === 'schema'}
|
||||
onClick={() => setTab('schema')}
|
||||
label={t('timeTravel.tabs.schema', { defaultValue: 'Структура и поля' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="px-6 py-10 min-h-[180px] flex items-center justify-center text-mute text-body">
|
||||
{tab === 'changes' && (
|
||||
<span>
|
||||
{isAtNow
|
||||
? t('timeTravel.empty.same', {
|
||||
defaultValue: 'Между этими версиями нет изменений.',
|
||||
})
|
||||
: t('timeTravel.placeholder.changes', {
|
||||
defaultValue:
|
||||
'Diff endpoint /api/v1/dictionaries/{name}/changelog · backend pending.',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{tab === 'records' && (
|
||||
<span>
|
||||
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
|
||||
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
|
||||
</span>
|
||||
)}
|
||||
{tab === 'schema' && (
|
||||
<span>
|
||||
v{detail.schemaVersion} ·{' '}
|
||||
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
|
||||
{pluralRu(
|
||||
Object.keys(detail.schemaJson.properties ?? {}).length,
|
||||
'поле',
|
||||
'поля',
|
||||
'полей',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-line flex items-center justify-between gap-3 mt-auto">
|
||||
<div className="text-cap text-mute tracking-[0.14em] uppercase">
|
||||
{t('timeTravel.disclaimer', {
|
||||
defaultValue: 'Snapshot не меняет данные — только показывает',
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 hover:border-line-2 text-body transition-colors"
|
||||
>
|
||||
{t('common.close', { defaultValue: 'Закрыть' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
className="h-9 px-4 rounded-md bg-navy text-on-accent hover:opacity-90 text-body font-semibold transition-opacity"
|
||||
>
|
||||
{isAtNow
|
||||
? t('timeTravel.applyNow', { defaultValue: 'Закрыть snapshot' })
|
||||
: t('timeTravel.apply', {
|
||||
defaultValue: `Открыть v${targetVersion} как readonly`,
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparePanel({
|
||||
label,
|
||||
version,
|
||||
ms,
|
||||
records,
|
||||
author,
|
||||
note,
|
||||
highlight,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
version: string
|
||||
ms: number
|
||||
records: number
|
||||
author?: string
|
||||
note?: string
|
||||
highlight?: boolean
|
||||
}) {
|
||||
const d = new Date(ms)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'px-6 py-4 space-y-1.5 border-r border-line last:border-r-0',
|
||||
highlight && 'bg-warn-bg/40',
|
||||
)}
|
||||
>
|
||||
<div className="text-cap text-mute tracking-[0.18em] uppercase">{label}</div>
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="text-mono text-title-md text-ink font-semibold">v{version}</span>
|
||||
<span className="text-mono text-cell text-mute">
|
||||
{d.toLocaleDateString(undefined, { dateStyle: 'short' })}
|
||||
{' · '}
|
||||
{d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-cell text-ink-2 flex items-center gap-1.5 flex-wrap">
|
||||
<span className="tabular-nums">{records}</span>
|
||||
<span>{pluralRu(records, 'запись', 'записи', 'записей')}</span>
|
||||
{note && (
|
||||
<span className="text-mute">
|
||||
· <span className="text-mono">{note}</span>
|
||||
</span>
|
||||
)}
|
||||
{author && <span className="text-mute">· {author}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Preset({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="h-8 px-3 rounded-full border border-line bg-surface text-cell text-ink-2 hover:border-ink-2 hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StepBtn({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'h-8 px-3 text-body border-r border-line last:border-r-0 transition-colors',
|
||||
active ? 'bg-navy text-on-accent font-semibold' : 'bg-surface text-ink-2 hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TabBtn({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
label: string
|
||||
count?: number
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'pb-2 text-body inline-flex items-center gap-1.5 border-b-2 transition-colors',
|
||||
active
|
||||
? 'border-ink text-ink font-semibold'
|
||||
: 'border-transparent text-ink-2 hover:text-ink',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{count !== undefined && (
|
||||
<span className={cn('text-mono', active ? 'text-mute' : 'text-mute')}>{count}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Slider =============================================================
|
||||
|
||||
function TimelineSlider({
|
||||
range,
|
||||
marks,
|
||||
value,
|
||||
onChange,
|
||||
currentVersion,
|
||||
}: {
|
||||
range: { min: number; max: number }
|
||||
marks: number[]
|
||||
value: number
|
||||
onChange: (ms: number) => void
|
||||
currentVersion: string
|
||||
}) {
|
||||
const span = range.max - range.min || 1
|
||||
const pct = ((value - range.min) / span) * 100
|
||||
|
||||
const dateLabel = new Date(value).toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* Version labels (top) — single current marker для текущей schema version.
|
||||
* Когда backend получит snapshots endpoint — здесь будут все версии. */}
|
||||
<div className="relative h-5 mb-1">
|
||||
<span
|
||||
className="absolute -translate-x-1/2 text-mono text-cap text-accent font-semibold"
|
||||
style={{ left: '100%' }}
|
||||
>
|
||||
v{currentVersion}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Track + cursor (range input для drag UX) */}
|
||||
<div className="relative h-6">
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[2px] bg-accent/60 rounded-full" />
|
||||
{/* Mark ticks */}
|
||||
{marks.map((m) => {
|
||||
const p = ((m - range.min) / span) * 100
|
||||
if (p < 0 || p > 100) return null
|
||||
return (
|
||||
<span
|
||||
key={m}
|
||||
className="absolute top-1/2 -translate-y-1/2 w-px h-3 bg-accent/40"
|
||||
style={{ left: `${p}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{/* Cursor */}
|
||||
<span
|
||||
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 size-5 rounded-full bg-ink border-2 border-surface shadow-md pointer-events-none"
|
||||
style={{ left: `${pct}%` }}
|
||||
/>
|
||||
{/* Hidden range input для drag */}
|
||||
<input
|
||||
type="range"
|
||||
min={range.min}
|
||||
max={range.max}
|
||||
step={86_400_000} // day
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
aria-label="time-travel slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date labels (bottom) — start / value / now */}
|
||||
<div className="relative h-4 mt-1 text-mono text-cap text-mute">
|
||||
<span className="absolute left-0">
|
||||
{new Date(range.min).toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="absolute -translate-x-1/2 text-accent font-semibold"
|
||||
style={{ left: `${pct}%` }}
|
||||
>
|
||||
{dateLabel}
|
||||
</span>
|
||||
<span className="absolute right-0">
|
||||
{new Date(range.max).toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function pluralRu(n: number, one: string, few: string, many: string): string {
|
||||
const mod10 = n % 10
|
||||
const mod100 = n % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return one
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few
|
||||
return many
|
||||
}
|
||||
Reference in New Issue
Block a user