Add maintenance management features: implement maintenance endpoints in the API, enhance media upload handling to skip duplicate files, and update frontend routes and translations for maintenance operations.
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Api.Common;
|
||||||
|
using TeleWave.Application.Maintenance.ClearAllMedia;
|
||||||
|
using TeleWave.Application.Maintenance.DeleteAllShows;
|
||||||
|
using TeleWave.Application.Maintenance.DeleteShowMedia;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class MaintenanceEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapMaintenanceEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/maintenance")
|
||||||
|
.WithTags("Admin.Maintenance")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapPost("/clear-media", ClearMedia).Produces<int>();
|
||||||
|
admin.MapPost("/clear-shows", ClearShows).Produces<int>();
|
||||||
|
admin.MapPost("/shows/{showId:guid}/clear-media", ClearShowMedia).Produces<int>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ClearMedia(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ClearAllMediaCommand(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ClearShows(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteAllShowsCommand(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ClearShowMedia(
|
||||||
|
Guid showId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteShowMediaCommand(showId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,7 @@ app.MapMediaEndpoints();
|
|||||||
app.MapShowEndpoints();
|
app.MapShowEndpoints();
|
||||||
app.MapChannelEndpoints();
|
app.MapChannelEndpoints();
|
||||||
app.MapStreamingEndpoints();
|
app.MapStreamingEndpoints();
|
||||||
|
app.MapMaintenanceEndpoints();
|
||||||
|
|
||||||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Maintenance.ClearAllMedia;
|
||||||
|
|
||||||
|
/// <summary>Удаляет ВСЕ медиа-ассеты (строки + файлы) и связанное расписание. Опасная операция.</summary>
|
||||||
|
public sealed record ClearAllMediaCommand : ICommand<Result<int>>;
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Maintenance.ClearAllMedia;
|
||||||
|
|
||||||
|
public sealed class ClearAllMediaCommandHandler(IAppDbContext dbContext, IMediaStorage storage)
|
||||||
|
: ICommandHandler<ClearAllMediaCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
ClearAllMediaCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var assets = await dbContext.MediaAssets
|
||||||
|
.Select(a => new { a.Id, a.OriginalExtension })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var asset in assets)
|
||||||
|
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
|
||||||
|
|
||||||
|
// Расписание ссылается на удаляемые ассеты — чистим его тоже.
|
||||||
|
await dbContext.ScheduleEntries.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
var deleted = await dbContext.MediaAssets.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Maintenance.DeleteAllShows;
|
||||||
|
|
||||||
|
/// <summary>Удаляет ВСЕ шоу (с сериями через каскад). Медиа-ассеты остаются в библиотеке.</summary>
|
||||||
|
public sealed record DeleteAllShowsCommand : ICommand<Result<int>>;
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Maintenance.DeleteAllShows;
|
||||||
|
|
||||||
|
public sealed class DeleteAllShowsCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<DeleteAllShowsCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
DeleteAllShowsCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Серии удаляются каскадом (FK Show → ShowEpisode: OnDelete Cascade).
|
||||||
|
var deleted = await dbContext.Shows.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
return Result.Success(deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Maintenance.DeleteShowMedia;
|
||||||
|
|
||||||
|
/// <summary>Удаляет медиа-ассеты (файлы + строки) всех серий указанного шоу и очищает его серии.</summary>
|
||||||
|
public sealed record DeleteShowMediaCommand(Guid ShowId) : ICommand<Result<int>>;
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Maintenance.DeleteShowMedia;
|
||||||
|
|
||||||
|
public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMediaStorage storage)
|
||||||
|
: ICommandHandler<DeleteShowMediaCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
DeleteShowMediaCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var show = await dbContext.Shows
|
||||||
|
.Include(s => s.Episodes)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
|
||||||
|
if (show is null)
|
||||||
|
return Result.Failure<int>(ShowErrors.NotFound);
|
||||||
|
|
||||||
|
var episodes = show.Episodes.ToList();
|
||||||
|
var assetIds = episodes.Select(e => e.MediaAssetId).Distinct().ToList();
|
||||||
|
|
||||||
|
var assets = await dbContext.MediaAssets
|
||||||
|
.Where(a => assetIds.Contains(a.Id))
|
||||||
|
.Select(a => new { a.Id, a.OriginalExtension })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var asset in assets)
|
||||||
|
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
|
||||||
|
|
||||||
|
await dbContext.ScheduleEntries
|
||||||
|
.Where(e => assetIds.Contains(e.MediaAssetId))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
await dbContext.MediaAssets
|
||||||
|
.Where(a => assetIds.Contains(a.Id))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Серии шоу теперь указывают на удалённые ассеты — убираем их (сохранится через UnitOfWork).
|
||||||
|
foreach (var episode in episodes)
|
||||||
|
show.RemoveEpisode(episode.Id);
|
||||||
|
|
||||||
|
return Result.Success(assets.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,4 +33,9 @@ public static class MediaErrors
|
|||||||
"Media.SourceNotFound",
|
"Media.SourceNotFound",
|
||||||
"Исходный файл не найден в хранилище."
|
"Исходный файл не найден в хранилище."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static readonly Error DuplicateFileName = Error.Conflict(
|
||||||
|
"Media.DuplicateFileName",
|
||||||
|
"Файл с таким именем уже загружен."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
@@ -15,6 +16,16 @@ public sealed class RegisterMediaAssetCommandHandler(
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
// Дедуп по имени: тот же файл уже в библиотеке (кроме проваленных — их разрешаем перезалить).
|
||||||
|
var duplicate = await dbContext.MediaAssets.AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.OriginalFileName == command.OriginalFileName
|
||||||
|
&& x.Status != MediaAssetStatus.Failed,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Failure<Guid>(MediaErrors.DuplicateFileName);
|
||||||
|
|
||||||
var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant();
|
var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant();
|
||||||
var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source);
|
var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { listShows } from '@/features/admin/shows/api'
|
||||||
|
import { clearAllMedia, deleteAllShows, deleteShowMedia } from './api'
|
||||||
|
|
||||||
|
export function MaintenancePanel() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [showId, setShowId] = useState('')
|
||||||
|
|
||||||
|
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||||
|
|
||||||
|
const onError = (error: unknown) =>
|
||||||
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
|
|
||||||
|
// Затрагиваются медиа/шоу/каналы — сбрасываем все связанные кэши.
|
||||||
|
const invalidateAll = () => {
|
||||||
|
for (const key of [['admin', 'media'], ['admin', 'shows'], ['admin', 'channels']])
|
||||||
|
void queryClient.invalidateQueries({ queryKey: key })
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMedia = useMutation({
|
||||||
|
mutationFn: clearAllMedia,
|
||||||
|
onSuccess: (count) => {
|
||||||
|
toast.success(t('admin.maintenance.doneCount', { count }))
|
||||||
|
invalidateAll()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearShows = useMutation({
|
||||||
|
mutationFn: deleteAllShows,
|
||||||
|
onSuccess: (count) => {
|
||||||
|
toast.success(t('admin.maintenance.doneCount', { count }))
|
||||||
|
invalidateAll()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearShowMedia = useMutation({
|
||||||
|
mutationFn: () => deleteShowMedia(showId),
|
||||||
|
onSuccess: (count) => {
|
||||||
|
setShowId('')
|
||||||
|
toast.success(t('admin.maintenance.doneCount', { count }))
|
||||||
|
invalidateAll()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmed = (message: string, run: () => void) => {
|
||||||
|
if (window.confirm(message)) run()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||||
|
<h2 className="crt-glow text-xl font-semibold">{t('admin.maintenance.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.maintenance.warning')}</p>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('admin.maintenance.clearMedia')}</CardTitle>
|
||||||
|
<CardDescription>{t('admin.maintenance.clearMediaHint')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={clearMedia.isPending}
|
||||||
|
onClick={() => confirmed(t('admin.maintenance.confirmClearMedia'), () => clearMedia.mutate())}
|
||||||
|
>
|
||||||
|
{t('admin.maintenance.clearMedia')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('admin.maintenance.clearShowMedia')}</CardTitle>
|
||||||
|
<CardDescription>{t('admin.maintenance.clearShowMediaHint')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-2">
|
||||||
|
<Select value={showId} onValueChange={setShowId}>
|
||||||
|
<SelectTrigger className="max-w-xs">
|
||||||
|
<SelectValue placeholder={t('admin.maintenance.pickShow')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{shows?.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!showId || clearShowMedia.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
confirmed(t('admin.maintenance.confirmClearShowMedia'), () => clearShowMedia.mutate())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('admin.maintenance.clearShowMedia')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('admin.maintenance.deleteShows')}</CardTitle>
|
||||||
|
<CardDescription>{t('admin.maintenance.deleteShowsHint')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={clearShows.isPending}
|
||||||
|
onClick={() => confirmed(t('admin.maintenance.confirmDeleteShows'), () => clearShows.mutate())}
|
||||||
|
>
|
||||||
|
{t('admin.maintenance.deleteShows')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { apiRequest } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export function clearAllMedia() {
|
||||||
|
return apiRequest<number>('/admin/maintenance/clear-media', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAllShows() {
|
||||||
|
return apiRequest<number>('/admin/maintenance/clear-shows', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteShowMedia(showId: string) {
|
||||||
|
return apiRequest<number>(`/admin/maintenance/shows/${showId}/clear-media`, { method: 'POST' })
|
||||||
|
}
|
||||||
@@ -64,15 +64,31 @@ export function MediaPanel() {
|
|||||||
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
|
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
|
||||||
|
|
||||||
// Массовая загрузка: файлы отправляются по одному (обработка всё равно в очереди по одному),
|
// Массовая загрузка: файлы отправляются по одному (обработка всё равно в очереди по одному),
|
||||||
// прогресс — «текущий/всего · %». Список обновляется после каждого файла.
|
// прогресс — «текущий/всего · %». Дубликаты по имени пропускаются заранее (сервер тоже отклонит).
|
||||||
const handleFiles = async (files: FileList) => {
|
const handleFiles = async (files: FileList) => {
|
||||||
const list = Array.from(files)
|
const list = Array.from(files)
|
||||||
|
|
||||||
|
let existing = new Set<string>()
|
||||||
|
try {
|
||||||
|
const all = await listMedia({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1000,
|
||||||
|
statuses: ['Pending', 'Processing', 'Ready'],
|
||||||
|
})
|
||||||
|
existing = new Set(all.items.map((a) => a.originalFileName))
|
||||||
|
} catch {
|
||||||
|
// Не удалось получить список — положимся на серверную проверку дубликатов.
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUpload = list.filter((f) => !existing.has(f.name))
|
||||||
|
const skipped = list.length - toUpload.length
|
||||||
|
|
||||||
let uploaded = 0
|
let uploaded = 0
|
||||||
for (let i = 0; i < list.length; i++) {
|
for (let i = 0; i < toUpload.length; i++) {
|
||||||
setUpload({ current: i + 1, total: list.length, percent: 0 })
|
setUpload({ current: i + 1, total: toUpload.length, percent: 0 })
|
||||||
try {
|
try {
|
||||||
await uploadMedia(list[i], (percent) =>
|
await uploadMedia(toUpload[i], (percent) =>
|
||||||
setUpload({ current: i + 1, total: list.length, percent }),
|
setUpload({ current: i + 1, total: toUpload.length, percent }),
|
||||||
)
|
)
|
||||||
uploaded++
|
uploaded++
|
||||||
invalidate()
|
invalidate()
|
||||||
@@ -83,6 +99,7 @@ export function MediaPanel() {
|
|||||||
setUpload(null)
|
setUpload(null)
|
||||||
if (fileInput.current) fileInput.current.value = ''
|
if (fileInput.current) fileInput.current.value = ''
|
||||||
if (uploaded > 0) toast.success(t('admin.media.uploadedCount', { count: uploaded }))
|
if (uploaded > 0) toast.success(t('admin.media.uploadedCount', { count: uploaded }))
|
||||||
|
if (skipped > 0) toast.message(t('admin.media.skippedDuplicates', { count: skipped }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Route as RegisterRouteImport } from './routes/register'
|
|||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||||
import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
|
import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
|
||||||
|
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
||||||
import { Route as AdminMediaRouteImport } from './routes/admin/media'
|
import { Route as AdminMediaRouteImport } from './routes/admin/media'
|
||||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||||
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
|
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
|
||||||
@@ -66,6 +67,11 @@ const AdminChannelsRoute = AdminChannelsRouteImport.update({
|
|||||||
path: '/channels',
|
path: '/channels',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
|
||||||
|
id: '/maintenance',
|
||||||
|
path: '/maintenance',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminMediaRoute = AdminMediaRouteImport.update({
|
const AdminMediaRoute = AdminMediaRouteImport.update({
|
||||||
id: '/media',
|
id: '/media',
|
||||||
path: '/media',
|
path: '/media',
|
||||||
@@ -115,6 +121,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||||
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
'/admin/shows': typeof AdminShowsRouteWithChildren
|
'/admin/shows': typeof AdminShowsRouteWithChildren
|
||||||
@@ -131,6 +138,7 @@ export interface FileRoutesByTo {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
@@ -149,6 +157,7 @@ export interface FileRoutesById {
|
|||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||||
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
'/admin/shows': typeof AdminShowsRouteWithChildren
|
'/admin/shows': typeof AdminShowsRouteWithChildren
|
||||||
@@ -169,6 +178,7 @@ export interface FileRouteTypes {
|
|||||||
| '/register'
|
| '/register'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/admin/channels'
|
| '/admin/channels'
|
||||||
|
| '/admin/maintenance'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
| '/admin/shows'
|
| '/admin/shows'
|
||||||
@@ -185,6 +195,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/register'
|
| '/register'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
|
| '/admin/maintenance'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
@@ -202,6 +213,7 @@ export interface FileRouteTypes {
|
|||||||
| '/register'
|
| '/register'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/admin/channels'
|
| '/admin/channels'
|
||||||
|
| '/admin/maintenance'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
| '/admin/shows'
|
| '/admin/shows'
|
||||||
@@ -280,6 +292,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminChannelsRouteImport
|
preLoaderRoute: typeof AdminChannelsRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/maintenance': {
|
||||||
|
id: '/admin/maintenance'
|
||||||
|
path: '/maintenance'
|
||||||
|
fullPath: '/admin/maintenance'
|
||||||
|
preLoaderRoute: typeof AdminMaintenanceRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/media': {
|
'/admin/media': {
|
||||||
id: '/admin/media'
|
id: '/admin/media'
|
||||||
path: '/media'
|
path: '/media'
|
||||||
@@ -369,6 +388,7 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
|
|||||||
|
|
||||||
interface AdminRouteChildren {
|
interface AdminRouteChildren {
|
||||||
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
|
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
|
||||||
|
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
||||||
AdminMediaRoute: typeof AdminMediaRoute
|
AdminMediaRoute: typeof AdminMediaRoute
|
||||||
AdminRolesRoute: typeof AdminRolesRoute
|
AdminRolesRoute: typeof AdminRolesRoute
|
||||||
AdminShowsRoute: typeof AdminShowsRouteWithChildren
|
AdminShowsRoute: typeof AdminShowsRouteWithChildren
|
||||||
@@ -378,6 +398,7 @@ interface AdminRouteChildren {
|
|||||||
|
|
||||||
const AdminRouteChildren: AdminRouteChildren = {
|
const AdminRouteChildren: AdminRouteChildren = {
|
||||||
AdminChannelsRoute: AdminChannelsRouteWithChildren,
|
AdminChannelsRoute: AdminChannelsRouteWithChildren,
|
||||||
|
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
||||||
AdminMediaRoute: AdminMediaRoute,
|
AdminMediaRoute: AdminMediaRoute,
|
||||||
AdminRolesRoute: AdminRolesRoute,
|
AdminRolesRoute: AdminRolesRoute,
|
||||||
AdminShowsRoute: AdminShowsRouteWithChildren,
|
AdminShowsRoute: AdminShowsRouteWithChildren,
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ function AdminLayout() {
|
|||||||
>
|
>
|
||||||
{t('admin.users.title')}
|
{t('admin.users.title')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin/maintenance"
|
||||||
|
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||||
|
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||||
|
>
|
||||||
|
{t('admin.maintenance.title')}
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { MaintenancePanel } from '@/features/admin/maintenance/MaintenancePanel'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/maintenance')({ component: MaintenancePanel })
|
||||||
@@ -106,6 +106,7 @@ const resources = {
|
|||||||
upload: 'Загрузить',
|
upload: 'Загрузить',
|
||||||
uploaded: 'Файл загружен, идёт обработка',
|
uploaded: 'Файл загружен, идёт обработка',
|
||||||
uploadedCount: 'Загружено файлов: {{count}}',
|
uploadedCount: 'Загружено файлов: {{count}}',
|
||||||
|
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
|
||||||
filterActive: 'Активные',
|
filterActive: 'Активные',
|
||||||
filterAll: 'Все',
|
filterAll: 'Все',
|
||||||
name: 'Файл',
|
name: 'Файл',
|
||||||
@@ -176,6 +177,21 @@ const resources = {
|
|||||||
schedule: 'Расписание (12 ч)',
|
schedule: 'Расписание (12 ч)',
|
||||||
noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»',
|
noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»',
|
||||||
},
|
},
|
||||||
|
maintenance: {
|
||||||
|
title: 'Обслуживание',
|
||||||
|
warning: 'Операции необратимы — удаляют данные и файлы навсегда.',
|
||||||
|
clearMedia: 'Очистить все медиа',
|
||||||
|
clearMediaHint: 'Удалит все загруженные файлы и их нарезку, а также расписание каналов.',
|
||||||
|
confirmClearMedia: 'Удалить ВСЕ медиа-файлы безвозвратно?',
|
||||||
|
clearShowMedia: 'Удалить медиа шоу',
|
||||||
|
clearShowMediaHint: 'Удалит файлы всех серий выбранного шоу и очистит его серии.',
|
||||||
|
confirmClearShowMedia: 'Удалить все медиа выбранного шоу безвозвратно?',
|
||||||
|
pickShow: 'Выберите шоу',
|
||||||
|
deleteShows: 'Удалить все шоу',
|
||||||
|
deleteShowsHint: 'Удалит все шоу и их серии. Медиа-файлы останутся в библиотеке.',
|
||||||
|
confirmDeleteShows: 'Удалить ВСЕ шоу безвозвратно?',
|
||||||
|
doneCount: 'Удалено: {{count}}',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -283,6 +299,7 @@ const resources = {
|
|||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
uploaded: 'File uploaded, processing started',
|
uploaded: 'File uploaded, processing started',
|
||||||
uploadedCount: 'Uploaded files: {{count}}',
|
uploadedCount: 'Uploaded files: {{count}}',
|
||||||
|
skippedDuplicates: 'Skipped duplicates: {{count}}',
|
||||||
filterActive: 'Active',
|
filterActive: 'Active',
|
||||||
filterAll: 'All',
|
filterAll: 'All',
|
||||||
name: 'File',
|
name: 'File',
|
||||||
@@ -353,6 +370,21 @@ const resources = {
|
|||||||
schedule: 'Schedule (12h)',
|
schedule: 'Schedule (12h)',
|
||||||
noSchedule: 'Schedule not built yet — click “Rebuild”',
|
noSchedule: 'Schedule not built yet — click “Rebuild”',
|
||||||
},
|
},
|
||||||
|
maintenance: {
|
||||||
|
title: 'Maintenance',
|
||||||
|
warning: 'These actions are irreversible — data and files are deleted permanently.',
|
||||||
|
clearMedia: 'Clear all media',
|
||||||
|
clearMediaHint: 'Deletes all uploaded files and their segments, plus channel schedules.',
|
||||||
|
confirmClearMedia: 'Permanently delete ALL media files?',
|
||||||
|
clearShowMedia: 'Delete show media',
|
||||||
|
clearShowMediaHint: "Deletes files of the selected show's episodes and clears its episodes.",
|
||||||
|
confirmClearShowMedia: 'Permanently delete all media of the selected show?',
|
||||||
|
pickShow: 'Pick a show',
|
||||||
|
deleteShows: 'Delete all shows',
|
||||||
|
deleteShowsHint: 'Deletes all shows and their episodes. Media files stay in the library.',
|
||||||
|
confirmDeleteShows: 'Permanently delete ALL shows?',
|
||||||
|
doneCount: 'Deleted: {{count}}',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user