Add grid auto-build functionality with profile management and preview capabilities
Implemented new API endpoints and frontend components for grid auto-building based on predefined profiles. Added functionality to list grid profiles, preview generated grids, and generate grids with specified parameters. Enhanced the TemplateEndpoints and related services to support these features, improving the user experience for channel scheduling. Localization updates were made to accommodate new features in both English and Russian.
This commit is contained in:
@@ -9,6 +9,11 @@ import type {
|
||||
CopyTemplateResultDto,
|
||||
CreatedIdResponse,
|
||||
EntryTraceDto,
|
||||
GenerateGridResultDto,
|
||||
GridGenerationMode,
|
||||
GridPlanDto,
|
||||
GridProfileDto,
|
||||
GridProfileKind,
|
||||
JunctionAmountMode,
|
||||
JunctionConditions,
|
||||
JunctionElementKind,
|
||||
@@ -89,6 +94,31 @@ export function getApplyDiff(channelId: string) {
|
||||
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
|
||||
}
|
||||
|
||||
/** Каталог профилей автосборки: имя, на что похоже и для какой библиотеки годится. */
|
||||
export function listGridProfiles() {
|
||||
return apiRequest<GridProfileDto[]>('/admin/grid-profiles')
|
||||
}
|
||||
|
||||
/** План автосборки: что будет создано и что снесено. Считается тем же кодом, что и сама сборка. */
|
||||
export function previewGrid(channelId: string, profile: GridProfileKind, mode: GridGenerationMode) {
|
||||
const query = new URLSearchParams({ profile, mode })
|
||||
return apiRequest<GridPlanDto>(
|
||||
`/admin/channels/${channelId}/template/grid-plan?${query.toString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Собирает сетку по профилю. План пересчитывается на сервере — с клиента едут только опции. */
|
||||
export function generateGrid(
|
||||
channelId: string,
|
||||
profile: GridProfileKind,
|
||||
mode: GridGenerationMode,
|
||||
) {
|
||||
return apiRequest<GenerateGridResultDto>(`/admin/channels/${channelId}/template/generate`, {
|
||||
method: 'POST',
|
||||
body: { profile, mode },
|
||||
})
|
||||
}
|
||||
|
||||
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
|
||||
export function copyTemplateTo(channelId: string, targetChannelId: string) {
|
||||
return apiRequest<CopyTemplateResultDto>(
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import type { GridGenerationMode, GridPlanSlotDto, GridProfileKind } from '@/shared/api/types'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { DurationLabel } from '../../groups/DurationLabel'
|
||||
import { generateGrid, listGridProfiles, previewGrid } from '../api'
|
||||
|
||||
/**
|
||||
* Автосборка сетки: профиль + режим → предпросмотр → создание. План с клиента не уезжает —
|
||||
* команда пересчитывает его сама, поэтому показанное и созданное не могут разойтись.
|
||||
*
|
||||
* Предпросмотр обязателен именно из-за режима пересборки: он сносит все слоты шаблона, и увидеть,
|
||||
* что взамен, надо до нажатия, а не после.
|
||||
*/
|
||||
export function GenerateGridDialog({
|
||||
channelId,
|
||||
onClose,
|
||||
onGenerated,
|
||||
onError,
|
||||
}: Readonly<{
|
||||
channelId: string
|
||||
onClose: () => void
|
||||
onGenerated: () => void
|
||||
onError: (error: unknown) => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [profile, setProfile] = useState<GridProfileKind>('Mixed')
|
||||
const [mode, setMode] = useState<GridGenerationMode>('FillGaps')
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: qk.gridProfiles.all,
|
||||
queryFn: listGridProfiles,
|
||||
})
|
||||
|
||||
const { data: plan, isFetching } = useQuery({
|
||||
queryKey: qk.channels.gridPlan(channelId, profile, mode),
|
||||
queryFn: () => previewGrid(channelId, profile, mode),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () => generateGrid(channelId, profile, mode),
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
t('admin.channels.generate.done', { created: result.created, removed: result.removed }),
|
||||
)
|
||||
onGenerated()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const covered =
|
||||
plan && plan.freeMinutes > 0 ? Math.round((plan.coveredMinutes / plan.freeMinutes) * 100) : 0
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.channels.generate.title')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.channels.generate.hint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
{/* Профиль — то, ради чего окно и открыли: ритм суток задаёт всё остальное. */}
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{(profiles ?? []).map((item) => (
|
||||
<button
|
||||
key={item.kind}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-0.5 rounded-md border px-3 py-2 text-left transition-colors',
|
||||
item.kind === profile
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50',
|
||||
)}
|
||||
onClick={() => setProfile(item.kind)}
|
||||
>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.reference}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(['FillGaps', 'Rebuild'] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant={value === mode ? 'default' : 'outline'}
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{t(`admin.channels.generate.modes.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(`admin.channels.generate.modeHints.${mode}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isFetching && <p className="text-muted-foreground">{t('common.loading')}</p>}
|
||||
|
||||
{plan && !isFetching && (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
{t('admin.channels.generate.summary', {
|
||||
slots: plan.slots.length,
|
||||
percent: covered,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{plan.slotsToRemove > 0 && (
|
||||
<p className="flex items-center gap-1.5 text-amber-500">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{t('admin.channels.generate.willRemove', { count: plan.slotsToRemove })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan.fallbackGroupName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.generate.fallback', { name: plan.fallbackGroupName })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan.notes.length > 0 && (
|
||||
<ul className="flex flex-col gap-0.5 text-xs text-amber-500">
|
||||
{plan.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{plan.slots.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('admin.channels.generate.nothing')}</p>
|
||||
) : (
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-xs">
|
||||
{plan.slots.map((slot, index) => (
|
||||
<PlanRow key={`${slot.weekday}-${slot.start}-${index}`} slot={slot} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={generate.isPending || isFetching || !plan || plan.slots.length === 0}
|
||||
onClick={() => generate.mutate()}
|
||||
>
|
||||
{t('admin.channels.generate.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanRow({ slot }: Readonly<{ slot: GridPlanSlotDto }>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 px-3 py-1">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">
|
||||
{slot.weekday === null
|
||||
? t('admin.channels.everyDay')
|
||||
: t(`admin.channels.weekdays.${slot.weekday}`)}
|
||||
</span>
|
||||
<span className="w-12 shrink-0 tabular-nums">{slot.start}</span>
|
||||
<span className="w-20 shrink-0 tabular-nums text-muted-foreground">
|
||||
<DurationLabel seconds={slot.durationMinutes * 60} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{slot.title}</span>
|
||||
<Badge variant="muted">{slot.block}</Badge>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Wand2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
updateSlot,
|
||||
} from '../api'
|
||||
import { toTime } from '../lib/format'
|
||||
import { GenerateGridDialog } from './GenerateGridDialog'
|
||||
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
|
||||
import { LayerList, ScheduleGrid } from './ScheduleGrid'
|
||||
import { SlotInspector, type SlotDraft } from './SlotInspector'
|
||||
@@ -50,6 +51,7 @@ export function GridTab({
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
||||
const [viewDate, setViewDate] = useState<string>('')
|
||||
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
const [copySource, setCopySource] = useState<number | null>(null)
|
||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||
@@ -302,6 +304,10 @@ export function GridTab({
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('admin.channels.newSlot')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setGenerating(true)}>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
{t('admin.channels.generate.action')}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
|
||||
<Input
|
||||
type="date"
|
||||
@@ -377,6 +383,15 @@ export function GridTab({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{generating && (
|
||||
<GenerateGridDialog
|
||||
channelId={channelId}
|
||||
onClose={() => setGenerating(false)}
|
||||
onGenerated={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { toast } from '@/shared/ui/toast-store'
|
||||
import { useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||
import { formatDuration } from './format'
|
||||
import { formatDuration, splitEta } from './format'
|
||||
import { ManualInboxDialog } from './ManualInboxDialog'
|
||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||
import { useUploadStore } from './upload-store'
|
||||
@@ -145,6 +145,15 @@ export function MediaPanel() {
|
||||
{formatDuration(stats.averageProcessingSeconds)}
|
||||
</span>
|
||||
</span>
|
||||
{/* Оценка появляется только когда есть что ждать: «Осталось: —» бесполезно. */}
|
||||
{stats.estimatedRemainingSeconds !== null && (
|
||||
<span title={t('admin.media.stats.eta')}>
|
||||
{t('admin.media.stats.etaShort')}:{' '}
|
||||
<span className="text-foreground tabular-nums">
|
||||
<EtaValue seconds={stats.estimatedRemainingSeconds} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -267,6 +276,21 @@ export function MediaPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* «Осталось: ~2 ч 40 мин». Тильда и округление до минут намеренные: это оценка по среднему времени,
|
||||
* и точная до секунды подпись читалась бы как обещание.
|
||||
*/
|
||||
function EtaValue({ seconds }: Readonly<{ seconds: number }>) {
|
||||
const { t } = useTranslation()
|
||||
const parts = splitEta(seconds)
|
||||
if (!parts) return <>—</>
|
||||
if (parts.hours === 0 && parts.minutes === 0) return <>{t('admin.media.stats.etaSoon')}</>
|
||||
|
||||
const hours = parts.hours > 0 ? `${parts.hours} ${t('admin.media.stats.hoursShort')}` : ''
|
||||
const minutes = parts.minutes > 0 ? `${parts.minutes} ${t('admin.media.stats.minutesShort')}` : ''
|
||||
return <>~{[hours, minutes].filter(Boolean).join(' ')}</>
|
||||
}
|
||||
|
||||
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
|
||||
@@ -8,3 +8,14 @@ export function formatDuration(seconds: number | null): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Оценка «сколько ещё ждать» — часами и минутами, без секунд. Точность здесь была бы мнимой: оценка
|
||||
* строится на среднем времени обработки, и «13:27:42» обещало бы то, чего никто не гарантирует.
|
||||
* Меньше минуты — не разбиваем на части, вернём нули: подпись всё равно скажет «меньше минуты».
|
||||
*/
|
||||
export function splitEta(seconds: number | null): { hours: number; minutes: number } | null {
|
||||
if (seconds == null) return null
|
||||
const total = Math.max(0, Math.round(seconds))
|
||||
return { hours: Math.floor(total / 3600), minutes: Math.round((total % 3600) / 60) }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ export const qk = {
|
||||
issues: (id: string) => ['admin', 'channels', id, 'issues'] as const,
|
||||
diff: (id: string) => ['admin', 'channels', id, 'diff'] as const,
|
||||
preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const,
|
||||
/** План автосборки — свой на каждую комбинацию профиля и режима. */
|
||||
gridPlan: (id: string, profile: string, mode: string) =>
|
||||
['admin', 'channels', id, 'grid-plan', profile, mode] as const,
|
||||
},
|
||||
|
||||
gridProfiles: {
|
||||
all: ['admin', 'grid-profiles'] as const,
|
||||
},
|
||||
|
||||
entries: {
|
||||
|
||||
@@ -109,6 +109,8 @@ export type MediaStatsDto = {
|
||||
queued: number
|
||||
processing: number
|
||||
averageProcessingSeconds: number | null
|
||||
/** Оценка времени до конца очереди, сек; null — очередь пуста либо среднее ещё не набралось. */
|
||||
estimatedRemainingSeconds: number | null
|
||||
}
|
||||
|
||||
// ── Библиотека (жанры) ─────────────────────────────────────────────────────
|
||||
@@ -692,6 +694,50 @@ export type CopyTemplateResultDto = {
|
||||
droppedBumperRefs: number
|
||||
}
|
||||
|
||||
// ── Автосборка сетки ──────────────────────────────────────────────────────
|
||||
|
||||
/** Архетип сетки: с какого типа канала списан ритм суток. */
|
||||
export type GridProfileKind = 'Mixed' | 'Animation' | 'Sitcom' | 'Music' | 'Movies' | 'Kids'
|
||||
|
||||
/** Что делать с тем, что в сетке уже есть. */
|
||||
export type GridGenerationMode = 'FillGaps' | 'Rebuild'
|
||||
|
||||
export type GridProfileDto = {
|
||||
kind: GridProfileKind
|
||||
name: string
|
||||
/** На что похоже — «как у 2×2». */
|
||||
reference: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Строка предпросмотра: будущий слот до того, как он создан. */
|
||||
export type GridPlanSlotDto = {
|
||||
layer: 'Main' | 'Weekend'
|
||||
weekday: number | null
|
||||
start: string
|
||||
durationMinutes: number
|
||||
title: string
|
||||
daypart: Daypart
|
||||
slotKind: SlotKind
|
||||
groupName: string | null
|
||||
/** Человеческое описание блока: «4 подряд», «до конца слота». */
|
||||
block: string
|
||||
}
|
||||
|
||||
export type GridPlanDto = {
|
||||
profile: GridProfileKind
|
||||
profileName: string
|
||||
slots: GridPlanSlotDto[]
|
||||
notes: string[]
|
||||
/** Больше нуля только при пересборке — столько слотов будет снесено. */
|
||||
slotsToRemove: number
|
||||
fallbackGroupName: string | null
|
||||
freeMinutes: number
|
||||
coveredMinutes: number
|
||||
}
|
||||
|
||||
export type GenerateGridResultDto = { created: number; removed: number }
|
||||
|
||||
/** Проверки сетки по правилам, до генерации (см. 5.1). */
|
||||
type TemplateIssueKind =
|
||||
| 'GroupEmpty'
|
||||
|
||||
@@ -318,6 +318,11 @@ export const en = {
|
||||
processingShort: 'Processing',
|
||||
average: 'Average processing time (recent)',
|
||||
averageShort: 'Avg time',
|
||||
eta: 'Estimated time until the queue drains: work left at the average rate, divided by the number of parallel transcodes',
|
||||
etaShort: 'Left',
|
||||
etaSoon: 'under a minute',
|
||||
hoursShort: 'h',
|
||||
minutesShort: 'min',
|
||||
},
|
||||
},
|
||||
gallery: {
|
||||
@@ -423,6 +428,27 @@ export const en = {
|
||||
newLayerName: 'New layer',
|
||||
addSlotHere: 'Add slot',
|
||||
newSlot: 'New slot',
|
||||
/** Grid auto-build by profile: plan preview and creation. */
|
||||
generate: {
|
||||
action: 'Build grid',
|
||||
title: 'Grid auto-build',
|
||||
hint: 'A profile sets the rhythm of the day: block length, what airs in prime time and what fills the night. Groups are picked from the existing ones by rating, content type and how many units they hold.',
|
||||
modes: {
|
||||
FillGaps: 'Fill gaps',
|
||||
Rebuild: 'Rebuild the week',
|
||||
},
|
||||
modeHints: {
|
||||
FillGaps: 'Existing slots are left alone — only uncovered time is filled.',
|
||||
Rebuild:
|
||||
'All template slots are removed; the weekend is built as a separate layer on top.',
|
||||
},
|
||||
summary: 'Slots to create: {{slots}} — that is {{percent}}% of the free week.',
|
||||
willRemove: 'Slots to remove: {{count}}.',
|
||||
fallback: 'The fallback group will be "{{name}}" — it covers pauses between slots.',
|
||||
nothing: 'Nothing to create: there is no free time in the grid.',
|
||||
create: 'Build',
|
||||
done: 'Created {{created}} slots, removed {{removed}}.',
|
||||
},
|
||||
editSlot: 'Slot',
|
||||
slotTitle: 'Block title',
|
||||
slotStart: 'Start',
|
||||
|
||||
@@ -319,6 +319,11 @@ export const ru = {
|
||||
processingShort: 'В обработке',
|
||||
average: 'Среднее время обработки (по недавним)',
|
||||
averageShort: 'Ср. время',
|
||||
eta: 'Примерное время до конца очереди: оставшаяся работа по среднему, делённая на число параллельных транскодов',
|
||||
etaShort: 'Осталось',
|
||||
etaSoon: 'меньше минуты',
|
||||
hoursShort: 'ч',
|
||||
minutesShort: 'мин',
|
||||
},
|
||||
},
|
||||
gallery: {
|
||||
@@ -424,6 +429,26 @@ export const ru = {
|
||||
newLayerName: 'Новый слой',
|
||||
addSlotHere: 'Добавить слот',
|
||||
newSlot: 'Новый слот',
|
||||
/** Автосборка сетки по профилю: предпросмотр плана и его создание. */
|
||||
generate: {
|
||||
action: 'Собрать сетку',
|
||||
title: 'Автосборка сетки',
|
||||
hint: 'Профиль задаёт ритм суток: длину блоков, что стоит в прайме и чем закрыта ночь. Группы под каждую полосу подбираются из существующих — по рейтингу, типу контента и запасу серий.',
|
||||
modes: {
|
||||
FillGaps: 'Заполнить дыры',
|
||||
Rebuild: 'Собрать неделю с нуля',
|
||||
},
|
||||
modeHints: {
|
||||
FillGaps: 'Существующие слоты не трогаются — застраивается только незакрытое время.',
|
||||
Rebuild: 'Все слоты шаблона сносятся; выходные строятся отдельным слоем поверх будних.',
|
||||
},
|
||||
summary: 'Будет создано слотов: {{slots}} — это {{percent}}% свободного времени недели.',
|
||||
willRemove: 'Будет снесено слотов: {{count}}.',
|
||||
fallback: 'Аварийной группой станет «{{name}}» — ею закрываются паузы между слотами.',
|
||||
nothing: 'Создавать нечего: свободного времени в сетке нет.',
|
||||
create: 'Собрать',
|
||||
done: 'Создано слотов: {{created}}, снесено: {{removed}}.',
|
||||
},
|
||||
editSlot: 'Слот',
|
||||
slotTitle: 'Название блока',
|
||||
slotStart: 'Начало',
|
||||
|
||||
Reference in New Issue
Block a user