This commit is contained in:
DmitriyFadin
2026-09-30 15:05:05 +03:00
commit ca18fc196d
122 changed files with 8794 additions and 0 deletions
@@ -0,0 +1,15 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public class BlockEditor
{
public void Move(Workout workout, int from, int to)
{
if (from < 0 || to < 0 || from >= workout.Blocks.Count || to >= workout.Blocks.Count)
return;
var block = workout.Blocks[from];
workout.Blocks.RemoveAt(from);
workout.Blocks.Insert(to, block);
}
}
@@ -0,0 +1,47 @@
using System.Text;
namespace TrainingPlanner.Application;
public class ClipboardTableParser(ExerciseImportOptions options)
{
public List<List<string>> Parse(string text)
{
if (text.Length > options.MaxClipboardCharacters)
throw new InvalidOperationException("Слишком большой объём данных. Вставьте таблицу частями.");
var rows = new List<List<string>>();
var row = new List<string>();
var cell = new StringBuilder();
var quoted = false;
for (var index = 0; index < text.Length; index++)
{
var character = text[index];
if (character == '"')
{
if (quoted && index + 1 < text.Length && text[index + 1] == '"')
{ cell.Append('"'); index++; }
else if (quoted || cell.Length == 0) quoted = !quoted;
else cell.Append(character);
}
else if (!quoted && (character == '\t' || character == '\r' || character == '\n'))
{
row.Add(cell.ToString());
cell.Clear();
if (character == '\t') continue;
rows.Add(row);
row = [];
if (character == '\r' && index + 1 < text.Length && text[index + 1] == '\n') index++;
}
else cell.Append(character);
}
if (quoted) throw new InvalidOperationException("Не удалось прочитать ячейку в кавычках. Проверьте скопированные данные.");
if (cell.Length > 0 || row.Count > 0)
{
row.Add(cell.ToString());
rows.Add(row);
}
while (rows.Count > 0 && rows[^1].All(string.IsNullOrWhiteSpace)) rows.RemoveAt(rows.Count - 1);
if (rows.Count > options.MaxRows)
throw new InvalidOperationException($"За один раз можно вставить не более {options.MaxRows} строк.");
return rows;
}
}
@@ -0,0 +1,7 @@
namespace TrainingPlanner.Application;
public class ExerciseImportOptions
{
public int MaxRows { get; set; }
public int MaxClipboardCharacters { get; set; }
}
@@ -0,0 +1,7 @@
namespace TrainingPlanner.Application;
public class ExerciseImportRow
{
public string Name { get; set; } = string.Empty;
public string VideoUrl { get; set; } = string.Empty;
}
@@ -0,0 +1,28 @@
namespace TrainingPlanner.Application;
public class ExerciseImportValidator(ExerciseImportOptions options)
{
public List<ExerciseImportRow> Validate(List<ExerciseImportRow> rows)
{
if (rows.Count > options.MaxRows)
throw new InvalidOperationException($"За один раз можно импортировать не более {options.MaxRows} строк.");
var result = new List<ExerciseImportRow>();
for (var index = 0; index < rows.Count; index++)
{
var row = rows[index];
if (string.IsNullOrWhiteSpace(row.Name) && string.IsNullOrWhiteSpace(row.VideoUrl)) continue;
try
{
PlanValidator.RequireName(row.Name, "Название упражнения");
PlanValidator.ValidateVideoUrl(row.VideoUrl.Trim());
}
catch (InvalidOperationException exception)
{
throw new InvalidOperationException($"Строка {index + 1}: {exception.Message}");
}
result.Add(new ExerciseImportRow { Name = row.Name.Trim(), VideoUrl = row.VideoUrl.Trim() });
}
if (result.Count == 0) throw new InvalidOperationException("Добавьте хотя бы одно упражнение.");
return result;
}
}
@@ -0,0 +1,20 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public static class ExerciseSearch
{
public static List<LibraryExercise> Find(IEnumerable<LibraryExercise> exercises, string query)
{
var search = Normalize(query);
var words = search.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return exercises
.Where(exercise => words.All(word => Normalize(exercise.Name).Contains(word)))
.OrderByDescending(exercise => Normalize(exercise.Name) == search)
.ThenByDescending(exercise => Normalize(exercise.Name).StartsWith(search))
.ThenBy(exercise => exercise.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
private static string Normalize(string value) => value.Trim().ToLowerInvariant().Replace('ё', 'е');
}
@@ -0,0 +1,11 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public interface IClientRepository
{
Task<List<Client>> GetAllAsync();
Task<Client?> GetAsync(int id);
Task SaveAsync(Client client);
Task DeleteAsync(int id);
}
@@ -0,0 +1,11 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public interface IExerciseGroupRepository
{
Task<List<ExerciseGroup>> GetAllAsync();
Task SaveAsync(ExerciseGroup group);
Task AddExerciseAsync(int groupId, int exerciseId);
Task DeleteAsync(int id);
}
@@ -0,0 +1,6 @@
namespace TrainingPlanner.Application;
public interface IExerciseImporter
{
Task<int> ImportAsync(List<ExerciseImportRow> rows, int? groupId, string newGroupName);
}
@@ -0,0 +1,11 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public interface IExerciseRepository
{
Task<List<LibraryExercise>> GetAllAsync();
Task SaveAsync(LibraryExercise exercise);
Task DeleteAsync(int id);
Task MoveAsync(int id, int? groupId);
}
@@ -0,0 +1,8 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public interface IPlanPdfExporter
{
byte[] Export(TrainingPlan plan, Client client);
}
@@ -0,0 +1,9 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public interface IPlanReader
{
Task<TrainingPlan?> GetAsync(int id, string trainerId);
Task<Client?> GetClientAsync(int id, string trainerId);
}
@@ -0,0 +1,11 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public interface IPlanRepository
{
Task<List<TrainingPlan>> GetForClientAsync(int clientId);
Task<TrainingPlan?> GetAsync(int id);
Task SaveAsync(TrainingPlan plan);
Task DeleteAsync(int id);
}
@@ -0,0 +1,6 @@
namespace TrainingPlanner.Application;
public interface ITrainerContext
{
Task<string> GetTrainerIdAsync();
}
@@ -0,0 +1,63 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public class PlanCopier
{
public TrainingPlan Copy(TrainingPlan source, string name)
{
return new TrainingPlan
{
ClientId = source.ClientId,
Name = name,
Weeks = source.Weeks.Select(CopyWeek).ToList()
};
}
public TrainingWeek CopyWeek(TrainingWeek source)
{
return new TrainingWeek
{
Position = source.Position,
Workouts = source.Workouts.Select(CopyWorkout).ToList()
};
}
public Workout CopyWorkout(Workout source)
{
return new Workout
{
Name = source.Name,
Duration = source.Duration,
Position = source.Position,
Blocks = source.Blocks.Select(CopyBlock).ToList()
};
}
public WorkoutBlock CopyBlock(WorkoutBlock source)
{
return new WorkoutBlock
{
Position = source.Position,
RestMinutes = source.RestMinutes,
SetsCount = source.SetsCount,
Exercises = source.Exercises.Select(CopyExercise).ToList()
};
}
public WorkoutExercise CopyExercise(WorkoutExercise source)
{
return new WorkoutExercise
{
Position = source.Position,
Name = source.Name,
VideoUrl = source.VideoUrl,
RequiresVideo = source.RequiresVideo,
Repetitions = source.Repetitions,
Weight = source.Weight,
WarmupSets = source.WarmupSets,
Notes = source.Notes,
IntensityPercent = source.IntensityPercent
};
}
}
@@ -0,0 +1,31 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public class PlanFactory(PlannerOptions options)
{
public TrainingPlan Create(int clientId, string name)
{
var plan = new TrainingPlan { ClientId = clientId, Name = name };
for (var index = 0; index < options.DefaultWeekCount; index++)
plan.Weeks.Add(new TrainingWeek { Position = index });
return plan;
}
public WorkoutBlock CreateBlock(LibraryExercise? source = null)
{
var exercise = new WorkoutExercise
{
Name = source?.Name ?? string.Empty,
VideoUrl = source?.VideoUrl ?? string.Empty,
Repetitions = options.DefaultRepetitions,
IntensityPercent = options.DefaultIntensityPercent
};
return new WorkoutBlock
{
SetsCount = options.DefaultSetsCount,
RestMinutes = options.DefaultRestMinutes,
Exercises = [exercise]
};
}
}
@@ -0,0 +1,60 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public class PlanValidator
{
public void Validate(TrainingPlan plan)
{
RequireName(plan.Name, "Название программы");
if (plan.Weeks.Count == 0)
throw new InvalidOperationException("Добавьте хотя бы одну неделю.");
for (var weekIndex = 0; weekIndex < plan.Weeks.Count; weekIndex++)
{
var week = plan.Weeks[weekIndex];
week.Position = weekIndex;
for (var workoutIndex = 0; workoutIndex < week.Workouts.Count; workoutIndex++)
{
var workout = week.Workouts[workoutIndex];
workout.Position = workoutIndex;
RequireName(workout.Name, "Название тренировки");
for (var blockIndex = 0; blockIndex < workout.Blocks.Count; blockIndex++)
{
var block = workout.Blocks[blockIndex];
block.Position = blockIndex;
ValidateBlock(block);
}
}
}
}
private static void ValidateBlock(WorkoutBlock block)
{
if (block.RestMinutes < 0 || block.SetsCount < 1 || block.Exercises.Count != 1)
throw new InvalidOperationException("Проверьте отдых и состав блока.");
for (var index = 0; index < block.Exercises.Count; index++)
{
var exercise = block.Exercises[index];
exercise.Position = index;
RequireName(exercise.Name, "Название упражнения");
ValidateVideoUrl(exercise.VideoUrl);
RequireName(exercise.Repetitions, "Повторения");
if (exercise.IntensityPercent is < 0 or > 100)
throw new InvalidOperationException("Процент от максимума должен быть от 0 до 100.");
}
}
public static void RequireName(string value, string field)
{
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException($"Заполните поле «{field}».");
}
public static void ValidateVideoUrl(string value)
{
if (!string.IsNullOrWhiteSpace(value) &&
(!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)))
throw new InvalidOperationException("Ссылка на видео должна начинаться с https:// или http://.");
}
}
@@ -0,0 +1,14 @@
namespace TrainingPlanner.Application;
public class PlannerOptions
{
public string DefaultWorkoutName { get; set; } = string.Empty;
public string DefaultWorkoutDuration { get; set; } = string.Empty;
public decimal DefaultIntensityPercent { get; set; }
public int DefaultWeekCount { get; set; }
public int DefaultSetsCount { get; set; }
public decimal DefaultRestMinutes { get; set; }
public string DefaultRepetitions { get; set; } = string.Empty;
public int UndoDeleteDurationSeconds { get; set; }
public int AutosaveDelayMilliseconds { get; set; }
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\TrainingPlanner.Domain\TrainingPlanner.Domain.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,11 @@
using TrainingPlanner.Domain;
namespace TrainingPlanner.Application;
public static class WorkoutTotals
{
public static decimal RestMinutes(Workout workout)
{
return workout.Blocks.Sum(block => block.SetsCount * block.RestMinutes);
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace TrainingPlanner.Domain;
public class Client
{
public int Id { get; set; }
public string TrainerId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public string Goal { get; set; } = string.Empty;
public string TrainingSchedule { get; set; } = string.Empty;
public string HealthStatus { get; set; } = string.Empty;
}
@@ -0,0 +1,8 @@
namespace TrainingPlanner.Domain;
public class ExerciseGroup
{
public int Id { get; set; }
public string TrainerId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
@@ -0,0 +1,11 @@
namespace TrainingPlanner.Domain;
public class LibraryExercise
{
public int Id { get; set; }
public string TrainerId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string VideoUrl { get; set; } = string.Empty;
public int? GroupId { get; set; }
public ExerciseGroup? Group { get; set; }
}
@@ -0,0 +1,13 @@
namespace TrainingPlanner.Domain;
public class TrainingPlan
{
public int Id { get; set; }
public int ClientId { get; set; }
public string TrainerId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
public int Version { get; set; }
public List<TrainingWeek> Weeks { get; set; } = [];
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,9 @@
namespace TrainingPlanner.Domain;
public class TrainingWeek
{
public int Id { get; set; }
public int TrainingPlanId { get; set; }
public int Position { get; set; }
public List<Workout> Workouts { get; set; } = [];
}
+11
View File
@@ -0,0 +1,11 @@
namespace TrainingPlanner.Domain;
public class Workout
{
public int Id { get; set; }
public int TrainingWeekId { get; set; }
public int Position { get; set; }
public string Name { get; set; } = string.Empty;
public string Duration { get; set; } = string.Empty;
public List<WorkoutBlock> Blocks { get; set; } = [];
}
@@ -0,0 +1,11 @@
namespace TrainingPlanner.Domain;
public class WorkoutBlock
{
public int Id { get; set; }
public int WorkoutId { get; set; }
public int Position { get; set; }
public int SetsCount { get; set; }
public decimal RestMinutes { get; set; }
public List<WorkoutExercise> Exercises { get; set; } = [];
}
@@ -0,0 +1,16 @@
namespace TrainingPlanner.Domain;
public class WorkoutExercise
{
public int Id { get; set; }
public int WorkoutBlockId { get; set; }
public int Position { get; set; }
public string Name { get; set; } = string.Empty;
public string Repetitions { get; set; } = string.Empty;
public string Weight { get; set; } = string.Empty;
public string WarmupSets { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public decimal? IntensityPercent { get; set; }
public string VideoUrl { get; set; } = string.Empty;
public bool RequiresVideo { get; set; }
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Data;
public class ClientRepository(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer)
: IClientRepository
{
public async Task<List<Client>> GetAllAsync()
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
return await db.Clients.AsNoTracking().Where(client => client.TrainerId == trainerId)
.OrderBy(client => client.Name).ToListAsync();
}
public async Task<Client?> GetAsync(int id)
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
return await db.Clients.AsNoTracking().SingleOrDefaultAsync(client => client.Id == id && client.TrainerId == trainerId);
}
public async Task DeleteAsync(int id)
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
var client = await db.Clients.SingleOrDefaultAsync(item => item.Id == id && item.TrainerId == trainerId)
?? throw new InvalidOperationException("Клиент не найден.");
await db.TrainingPlans.Where(plan => plan.ClientId == id && plan.TrainerId == trainerId)
.ExecuteDeleteAsync();
db.Clients.Remove(client);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
public async Task SaveAsync(Client client)
{
PlanValidator.RequireName(client.Name, "Имя клиента");
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
var entity = client.Id == 0 ? new Client { TrainerId = trainerId } :
await db.Clients.SingleOrDefaultAsync(item => item.Id == client.Id && item.TrainerId == trainerId)
?? throw new InvalidOperationException("Клиент не найден.");
entity.Name = client.Name.Trim();
entity.Notes = client.Notes;
entity.Goal = client.Goal;
entity.TrainingSchedule = client.TrainingSchedule;
entity.HealthStatus = client.HealthStatus;
if (entity.Id == 0) db.Clients.Add(entity);
await db.SaveChangesAsync();
client.Id = entity.Id;
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using TrainingPlanner.Infrastructure.Data;
namespace TrainingPlanner.Infrastructure.Data;
public class DatabaseInitializer(TrainingDbContext db, UserManager<IdentityUser> users, IConfiguration configuration)
{
public async Task InitializeAsync()
{
await db.Database.MigrateAsync();
if (await users.Users.AnyAsync()) return;
var name = configuration["BootstrapTrainer:UserName"];
var password = configuration["BootstrapTrainer:Password"];
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("Для первого запуска задайте BootstrapTrainer__UserName и BootstrapTrainer__Password.");
var result = await users.CreateAsync(new IdentityUser { UserName = name }, password);
if (!result.Succeeded)
throw new InvalidOperationException("Не удалось создать тренера: " + string.Join("; ", result.Errors.Select(error => error.Description)));
}
}
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Data;
public class ExerciseGroupRepository(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer)
: IExerciseGroupRepository
{
public async Task<List<ExerciseGroup>> GetAllAsync()
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
return await db.ExerciseGroups.AsNoTracking().Where(group => group.TrainerId == trainerId)
.OrderBy(group => group.Name).ToListAsync();
}
public async Task SaveAsync(ExerciseGroup group)
{
PlanValidator.RequireName(group.Name, "Название группы");
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
var entity = group.Id == 0 ? new ExerciseGroup { TrainerId = trainerId } :
await db.ExerciseGroups.SingleOrDefaultAsync(item => item.Id == group.Id && item.TrainerId == trainerId)
?? throw new InvalidOperationException("Группа не найдена.");
entity.Name = group.Name.Trim();
if (entity.Id == 0) db.ExerciseGroups.Add(entity);
await db.SaveChangesAsync();
group.Id = entity.Id;
}
public async Task AddExerciseAsync(int groupId, int exerciseId)
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
var group = await db.ExerciseGroups.SingleOrDefaultAsync(item => item.Id == groupId && item.TrainerId == trainerId)
?? throw new InvalidOperationException("Группа не найдена.");
var exercise = await db.LibraryExercises
.SingleOrDefaultAsync(item => item.Id == exerciseId && item.TrainerId == trainerId)
?? throw new InvalidOperationException("Упражнение не найдено.");
db.LibraryExercises.Add(new LibraryExercise
{
TrainerId = trainerId, GroupId = group.Id, Name = exercise.Name, VideoUrl = exercise.VideoUrl
});
await db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
// The database deletes this group's independent library exercises atomically.
var deleted = await db.ExerciseGroups.Where(group => group.Id == id && group.TrainerId == trainerId)
.ExecuteDeleteAsync();
if (deleted == 0)
throw new InvalidOperationException("Группа не найдена. Обновите список.");
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Data;
public class ExerciseImporter(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer,
ExerciseImportValidator validator) : IExerciseImporter
{
public async Task<int> ImportAsync(List<ExerciseImportRow> rows, int? groupId, string newGroupName)
{
var exercises = validator.Validate(rows);
if (groupId.HasValue && !string.IsNullOrWhiteSpace(newGroupName))
throw new InvalidOperationException("Выберите существующую группу или создайте новую.");
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
var group = groupId.HasValue ? await db.ExerciseGroups.SingleOrDefaultAsync(item =>
item.Id == groupId && item.TrainerId == trainerId)
?? throw new InvalidOperationException("Группа не найдена. Откройте импорт заново.") : null;
if (!string.IsNullOrWhiteSpace(newGroupName))
{
group = new ExerciseGroup { Name = newGroupName.Trim(), TrainerId = trainerId };
db.ExerciseGroups.Add(group);
}
db.LibraryExercises.AddRange(exercises.Select(row => new LibraryExercise
{
Name = row.Name, VideoUrl = row.VideoUrl, TrainerId = trainerId, Group = group
}));
// One SaveChanges transaction includes the new group and every imported exercise.
await db.SaveChangesAsync();
return exercises.Count;
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Data;
public class ExerciseRepository(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer)
: IExerciseRepository
{
public async Task<List<LibraryExercise>> GetAllAsync()
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
return await db.LibraryExercises.AsNoTracking().Include(item => item.Group).Where(item => item.TrainerId == trainerId)
.OrderBy(item => item.Name).ToListAsync();
}
public async Task SaveAsync(LibraryExercise exercise)
{
PlanValidator.RequireName(exercise.Name, "Название упражнения");
PlanValidator.ValidateVideoUrl(exercise.VideoUrl);
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
var entity = exercise.Id == 0 ? new LibraryExercise { TrainerId = trainerId } :
await db.LibraryExercises.Include(item => item.Group).SingleOrDefaultAsync(item => item.Id == exercise.Id && item.TrainerId == trainerId)
?? throw new InvalidOperationException("Упражнение не найдено.");
if (exercise.GroupId.HasValue && !await db.ExerciseGroups.AnyAsync(group =>
group.Id == exercise.GroupId && group.TrainerId == trainerId))
throw new InvalidOperationException("Группа не найдена. Обновите список.");
entity.Name = exercise.Name.Trim();
entity.VideoUrl = exercise.VideoUrl.Trim();
entity.GroupId = exercise.GroupId;
if (entity.Id == 0) db.LibraryExercises.Add(entity);
await db.SaveChangesAsync();
exercise.Id = entity.Id;
}
public async Task DeleteAsync(int id)
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
var deleted = await db.LibraryExercises
.Where(exercise => exercise.Id == id && exercise.TrainerId == trainerId)
.ExecuteDeleteAsync();
if (deleted == 0)
throw new InvalidOperationException("Упражнение не найдено. Обновите список.");
}
public async Task MoveAsync(int id, int? groupId)
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
if (groupId.HasValue && !await db.ExerciseGroups.AnyAsync(group => group.Id == groupId && group.TrainerId == trainerId))
throw new InvalidOperationException("Группа не найдена. Обновите список.");
var changed = await db.LibraryExercises.Where(exercise => exercise.Id == id && exercise.TrainerId == trainerId)
.ExecuteUpdateAsync(update => update.SetProperty(exercise => exercise.GroupId, groupId));
if (changed == 0)
throw new InvalidOperationException("Упражнение не найдено. Обновите список.");
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Data;
public class PlanReader(IDbContextFactory<TrainingDbContext> factory) : IPlanReader
{
public async Task<TrainingPlan?> GetAsync(int id, string trainerId)
{
await using var db = await factory.CreateDbContextAsync();
return await db.TrainingPlans.AsNoTracking().AsSplitQuery()
.Include(plan => plan.Weeks.OrderBy(week => week.Position))
.ThenInclude(week => week.Workouts.OrderBy(workout => workout.Position))
.ThenInclude(workout => workout.Blocks.OrderBy(block => block.Position))
.ThenInclude(block => block.Exercises.OrderBy(exercise => exercise.Position))
.SingleOrDefaultAsync(plan => plan.Id == id && plan.TrainerId == trainerId);
}
public async Task<Client?> GetClientAsync(int id, string trainerId)
{
await using var db = await factory.CreateDbContextAsync();
return await db.Clients.AsNoTracking()
.SingleOrDefaultAsync(client => client.Id == id && client.TrainerId == trainerId);
}
}
@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Data;
public class PlanRepository(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer,
PlanValidator validator, PlanCopier copier, IPlanReader reader) : IPlanRepository
{
public async Task<List<TrainingPlan>> GetForClientAsync(int clientId)
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
return await db.TrainingPlans.AsNoTracking()
.Where(plan => plan.ClientId == clientId && plan.TrainerId == trainerId)
.OrderByDescending(plan => plan.CreatedAtUtc).ToListAsync();
}
public async Task<TrainingPlan?> GetAsync(int id)
{
return await reader.GetAsync(id, await trainer.GetTrainerIdAsync());
}
public async Task DeleteAsync(int id)
{
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
var deleted = await db.TrainingPlans
.Where(plan => plan.Id == id && plan.TrainerId == trainerId).ExecuteDeleteAsync();
if (deleted == 0) throw new InvalidOperationException("Программа не найдена.");
}
public async Task SaveAsync(TrainingPlan plan)
{
validator.Validate(plan);
var trainerId = await trainer.GetTrainerIdAsync();
await using var db = await factory.CreateDbContextAsync();
if (!await db.Clients.AnyAsync(client => client.Id == plan.ClientId && client.TrainerId == trainerId))
throw new InvalidOperationException("Клиент не найден.");
await using var transaction = await db.Database.BeginTransactionAsync();
var stored = plan.Id == 0 ? new TrainingPlan
{
ClientId = plan.ClientId, TrainerId = trainerId, CreatedAtUtc = DateTime.UtcNow
} : await db.TrainingPlans.SingleOrDefaultAsync(item => item.Id == plan.Id && item.TrainerId == trainerId)
?? throw new InvalidOperationException("Программа не найдена.");
if (stored.ClientId != plan.ClientId)
throw new InvalidOperationException("Программа принадлежит другому клиенту.");
if (stored.Version != plan.Version)
throw new InvalidOperationException("Программа изменена в другой вкладке. Откройте её заново перед сохранением.");
stored.Name = plan.Name.Trim();
stored.UpdatedAtUtc = DateTime.UtcNow;
stored.Version++;
if (stored.Id == 0) db.TrainingPlans.Add(stored);
try
{
// Check the root version before replacing its private child graph, in one transaction.
await db.SaveChangesAsync();
await db.Set<TrainingWeek>().Where(week => week.TrainingPlanId == stored.Id).ExecuteDeleteAsync();
stored.Weeks = plan.Weeks.Select(copier.CopyWeek).ToList();
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Программа изменена в другой вкладке. Откройте её заново перед сохранением.");
}
plan.Id = stored.Id;
plan.Version = stored.Version;
plan.CreatedAtUtc = stored.CreatedAtUtc;
plan.UpdatedAtUtc = stored.UpdatedAtUtc;
}
}
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Data;
public class TrainingDbContext(DbContextOptions<TrainingDbContext> options)
: IdentityDbContext<IdentityUser>(options)
{
public DbSet<Client> Clients { get; set; }
public DbSet<LibraryExercise> LibraryExercises { get; set; }
public DbSet<ExerciseGroup> ExerciseGroups { get; set; }
public DbSet<TrainingPlan> TrainingPlans { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Client>().HasIndex(client => client.TrainerId);
builder.Entity<Client>().HasOne<IdentityUser>().WithMany()
.HasForeignKey(client => client.TrainerId).OnDelete(DeleteBehavior.Restrict);
builder.Entity<LibraryExercise>().HasOne<IdentityUser>().WithMany()
.HasForeignKey(exercise => exercise.TrainerId).OnDelete(DeleteBehavior.Restrict);
builder.Entity<ExerciseGroup>().HasOne<IdentityUser>().WithMany()
.HasForeignKey(group => group.TrainerId).OnDelete(DeleteBehavior.Restrict);
builder.Entity<LibraryExercise>().HasOne(exercise => exercise.Group).WithMany()
.HasForeignKey(exercise => exercise.GroupId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<TrainingPlan>().HasOne<Client>().WithMany()
.HasForeignKey(plan => plan.ClientId).OnDelete(DeleteBehavior.Restrict);
builder.Entity<TrainingPlan>().HasOne<IdentityUser>().WithMany()
.HasForeignKey(plan => plan.TrainerId).OnDelete(DeleteBehavior.Restrict);
builder.Entity<TrainingPlan>().Property(plan => plan.Version).IsConcurrencyToken();
builder.Entity<TrainingPlan>().HasMany(plan => plan.Weeks).WithOne()
.HasForeignKey(week => week.TrainingPlanId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<TrainingWeek>().HasMany(week => week.Workouts).WithOne()
.HasForeignKey(workout => workout.TrainingWeekId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<Workout>().HasMany(workout => workout.Blocks).WithOne()
.HasForeignKey(block => block.WorkoutId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<WorkoutBlock>().HasMany(block => block.Exercises).WithOne()
.HasForeignKey(exercise => exercise.WorkoutBlockId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,15 @@
namespace TrainingPlanner.Infrastructure.Pdf;
internal static class CameraImage
{
public static string Data { get; set; } = Load();
private static string Load()
{
using var resource = typeof(CameraImage).Assembly.GetManifestResourceStream("TrainingPlanner.Pdf.Camera.png")
?? throw new InvalidOperationException("Ресурс камеры для PDF не найден.");
using var stream = new MemoryStream();
resource.CopyTo(stream);
return "base64:" + Convert.ToBase64String(stream.ToArray());
}
}
@@ -0,0 +1,25 @@
using MigraDoc.DocumentObjectModel;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Pdf;
public class PdfClientTableBuilder(PdfOptions options)
{
public void Add(Section section, Client client)
{
var table = section.AddTable();
table.Borders.Color = Color.Parse(options.BorderColor);
table.Borders.Width = Unit.FromPoint(0.5);
table.TopPadding = table.BottomPadding = Unit.FromPoint(options.FontSize / 2);
table.AddColumn(Unit.FromCentimeter(options.ClientLabelWidthCentimeters));
table.AddColumn(Unit.FromCentimeter(options.ColumnWidthsCentimeters.Sum() - options.ClientLabelWidthCentimeters));
var labels = new[] { "КЛИЕНТ:", "ЦЕЛЬ:", "РЕЖИМ ТРЕНИРОВОК:", "СОСТОЯНИЕ ЗДОРОВЬЯ:" };
var values = new[] { client.Name, client.Goal, client.TrainingSchedule, client.HealthStatus };
for (var index = 0; index < labels.Length; index++)
{
var row = table.AddRow();
row.Cells[0].AddParagraph(labels[index]);
row.Cells[1].AddParagraph(values[index]);
}
}
}
@@ -0,0 +1,20 @@
namespace TrainingPlanner.Infrastructure.Pdf;
public class PdfOptions
{
public string FontPath { get; set; } = string.Empty;
public string BoldFontPath { get; set; } = string.Empty;
public string FontFamily { get; set; } = string.Empty;
public double FontSize { get; set; }
public double CameraSizePoints { get; set; }
public double HeadingSize { get; set; }
public double MarginCentimeters { get; set; }
public double ClientLabelWidthCentimeters { get; set; }
public int ClientDetailsGapLines { get; set; }
public string AccentColor { get; set; } = string.Empty;
public string HeaderBackground { get; set; } = string.Empty;
public string WeekBackground { get; set; } = string.Empty;
public string WorkoutBackground { get; set; } = string.Empty;
public string BorderColor { get; set; } = string.Empty;
public List<double> ColumnWidthsCentimeters { get; set; } = [];
}
@@ -0,0 +1,110 @@
using System.Globalization;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Pdf;
public class PdfWeekTableBuilder(PdfOptions options)
{
public void Add(Section section, TrainingWeek week)
{
var table = section.AddTable();
table.Borders.Color = Color.Parse(options.BorderColor);
table.Borders.Width = Unit.FromPoint(0.5);
table.TopPadding = table.BottomPadding = Unit.FromPoint(options.FontSize / 2);
for (var index = 0; index < options.ColumnWidthsCentimeters.Count; index++)
{
var column = table.AddColumn(Unit.FromCentimeter(options.ColumnWidthsCentimeters[index]));
if (index > 3)
{
column.Format.Alignment = ParagraphAlignment.Center;
column.Format.SpaceAfter = Unit.FromPoint(0);
}
}
AddTitle(table, $"Неделя {week.Position + 1}", options.WeekBackground, true);
var header = table.AddRow();
header.HeadingFormat = true;
header.Shading.Color = Color.Parse(options.HeaderBackground);
header.Format.Font.Bold = true;
var labels = new[] { "№", "Название упражнения", "Разминочные подходы", "Примечание",
"Кол-во подходов", "Кол-во повторений", "Ссылка на видео", "Вес, кг", "%",
"Отдых, мин", "Съёмка", "Время тренировки" };
for (var index = 0; index < labels.Length; index++)
header.Cells[index].AddParagraph(labels[index]);
foreach (var workout in week.Workouts)
AddWorkout(table, workout);
for (var rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++)
for (var index = 4; index < table.Columns.Count; index++)
table.Rows[rowIndex].Cells[index].VerticalAlignment = VerticalAlignment.Center;
}
private void AddWorkout(Table table, Workout workout)
{
AddTitle(table, workout.Name, options.WorkoutBackground, false);
foreach (var block in workout.Blocks)
AddExercise(table, block);
if (workout.Blocks.Count == 0)
{
var empty = table.AddRow();
empty.Cells[0].MergeRight = table.Columns.Count - 1;
empty.Cells[0].AddParagraph("Упражнения не назначены.");
}
table.Rows[table.Rows.Count - 1].KeepWith = 1;
var total = table.AddRow();
total.Format.Font.Bold = true;
total.Shading.Color = Color.Parse(options.HeaderBackground);
total.Cells[0].MergeRight = 8;
total.Cells[0].AddParagraph("Общее время");
total.Cells[9].AddParagraph(Number(WorkoutTotals.RestMinutes(workout)));
total.Cells[11].AddParagraph(workout.Duration);
}
private static void AddTitle(Table table, string text, string color, bool repeat)
{
var row = table.AddRow();
row.HeadingFormat = repeat;
row.KeepWith = 1;
row.Cells[0].MergeRight = table.Columns.Count - 1;
row.Shading.Color = Color.Parse(color);
row.Format.Font.Bold = true;
row.Cells[0].AddParagraph(text);
}
private void AddExercise(Table table, WorkoutBlock block)
{
var exercise = block.Exercises[0];
var row = table.AddRow();
row.Cells[0].AddParagraph((block.Position + 1).ToString(CultureInfo.InvariantCulture));
row.Cells[1].AddParagraph(exercise.Name);
row.Cells[2].AddParagraph(exercise.WarmupSets);
row.Cells[3].AddParagraph(exercise.Notes);
row.Cells[4].AddParagraph(block.SetsCount.ToString(CultureInfo.InvariantCulture));
row.Cells[5].AddParagraph(exercise.Repetitions);
if (!string.IsNullOrWhiteSpace(exercise.VideoUrl))
{
var link = row.Cells[6].AddParagraph().AddHyperlink(exercise.VideoUrl, HyperlinkType.Url);
// Invisible break opportunities keep long URLs within the cell; the target stays unchanged.
link.AddText(string.Join("\u200B", exercise.VideoUrl.Select(character => character.ToString())));
link.Font.Color = Color.Parse(options.AccentColor);
link.Font.Underline = Underline.Single;
}
row.Cells[7].AddParagraph(exercise.Weight);
row.Cells[8].AddParagraph(exercise.IntensityPercent.HasValue ? Number(exercise.IntensityPercent.Value) : "");
row.Cells[9].AddParagraph(Number(block.RestMinutes));
if (exercise.RequiresVideo)
{
var cell = row.Cells[10];
cell.VerticalAlignment = VerticalAlignment.Center;
var paragraph = cell.AddParagraph();
paragraph.Format.Alignment = ParagraphAlignment.Center;
paragraph.Format.SpaceBefore = Unit.FromPoint(0);
paragraph.Format.SpaceAfter = Unit.FromPoint(0);
var image = paragraph.AddImage(CameraImage.Data);
image.Width = image.Height = Unit.FromPoint(options.CameraSizePoints);
}
}
private static string Number(decimal value) => value.ToString("0.##", CultureInfo.GetCultureInfo("ru-RU"));
}
@@ -0,0 +1,63 @@
using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using PdfSharp.Fonts;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Infrastructure.Pdf;
public class PlanPdfExporter : IPlanPdfExporter
{
private readonly PdfOptions options;
private readonly PdfWeekTableBuilder tables;
public PlanPdfExporter(PdfOptions options)
{
this.options = options;
tables = new PdfWeekTableBuilder(options);
GlobalFontSettings.FontResolver = new PlannerFontResolver(options);
}
public byte[] Export(TrainingPlan plan, Client client)
{
new PlanValidator().Validate(plan);
var document = new Document();
var normal = document.Styles[StyleNames.Normal]!;
normal.Font.Name = options.FontFamily;
normal.Font.Size = options.FontSize;
normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(options.FontSize / 2);
var firstWeek = true;
foreach (var week in plan.Weeks)
{
var section = document.AddSection();
section.PageSetup.PageFormat = PageFormat.A4;
section.PageSetup.Orientation = Orientation.Landscape;
section.PageSetup.TopMargin = section.PageSetup.BottomMargin = Unit.FromCentimeter(options.MarginCentimeters);
section.PageSetup.LeftMargin = section.PageSetup.RightMargin = Unit.FromCentimeter(options.MarginCentimeters);
if (firstWeek)
{
new PdfClientTableBuilder(options).Add(section, client);
for (var line = 0; line < options.ClientDetailsGapLines; line++)
{
var spacer = section.AddParagraph();
spacer.Format.SpaceBefore = Unit.FromPoint(0);
spacer.Format.SpaceAfter = Unit.FromPoint(0);
spacer.Format.LineSpacingRule = LineSpacingRule.Single;
}
firstWeek = false;
}
var footer = section.Footers.Primary.AddParagraph();
footer.Format.Alignment = ParagraphAlignment.Right;
footer.AddPageField();
if (week.Workouts.Count == 0)
section.AddParagraph($"Неделя {week.Position + 1}. Тренировки не назначены.");
else
tables.Add(section, week);
}
var renderer = new PdfDocumentRenderer { Document = document };
renderer.RenderDocument();
using var stream = new MemoryStream();
renderer.PdfDocument.Save(stream, false);
return stream.ToArray();
}
}
@@ -0,0 +1,11 @@
using PdfSharp.Fonts;
namespace TrainingPlanner.Infrastructure.Pdf;
public class PlannerFontResolver(PdfOptions options) : IFontResolver
{
public FontResolverInfo ResolveTypeface(string familyName, bool bold, bool italic)
=> new(bold ? options.BoldFontPath : options.FontPath);
public byte[] GetFont(string faceName) => File.ReadAllBytes(faceName);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\TrainingPlanner.Application\TrainingPlanner.Application.csproj" />
<EmbeddedResource Include="Pdf/Resources/camera.png" LogicalName="TrainingPlanner.Pdf.Camera.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["TrainingPlanner.Web.styles.css"]" />
<link rel="icon" type="image/svg+xml" href="@Assets["favicon.svg"]" />
<ImportMap />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["planner.js"]">
</script>
<script src="@Assets["week-drag.js"]"></script>
<script src="@Assets["workout-drag.js"]"></script>
<script src="@Assets["library-drag.js"]"></script>
<script src="@Assets["exercise-picker.js"]"></script>
<script src="@Assets["exercise-import.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]">
</script>
</body>
</html>
@@ -0,0 +1,38 @@
<article class="block-card">
<header class="block-header">
<div class="actions">
<span class="drag-handle" draggable="true" @ondragstart="() => OnDrag.InvokeAsync()" title="Перетащить блок">⠿</span>
<span class="block-number">@Number.ToString("00")</span>
<strong>Упражнение</strong>
</div>
<div class="actions">
<button title="Вверх" aria-label="Переместить вверх" disabled="@IsFirst" @onclick="() => OnMove.InvokeAsync(-1)">↑</button>
<button title="Вниз" aria-label="Переместить вниз" disabled="@IsLast" @onclick="() => OnMove.InvokeAsync(1)">↓</button>
<button @onclick="() => OnCopy.InvokeAsync()">Копия</button>
<button class="danger-text" @onclick="() => OnDelete.InvokeAsync()">Удалить</button>
</div>
</header>
<div class="exercise-columns"><ExerciseEditor Exercise="Block.Exercises[0]" OnChanged="OnChanged" /></div>
<footer class="block-footer">
<div class="actions">
<label class="rest-input">Подходов
<input type="number" min="1" step="1" @bind="Block.SetsCount" @bind:after="Changed" />
</label>
<label class="rest-input">Отдых, мин
<input type="number" min="0" step="any" @bind="Block.RestMinutes" @bind:after="Changed" />
</label>
</div>
</footer>
</article>
@code {
[Parameter, EditorRequired] public WorkoutBlock Block { get; set; } = null!;
[Parameter] public int Number { get; set; }
[Parameter] public bool IsFirst { get; set; }
[Parameter] public bool IsLast { get; set; }
[Parameter] public EventCallback OnChanged { get; set; }
[Parameter] public EventCallback<int> OnMove { get; set; }
[Parameter] public EventCallback OnCopy { get; set; }
[Parameter] public EventCallback OnDelete { get; set; }
[Parameter] public EventCallback OnDrag { get; set; }
private Task Changed() => OnChanged.InvokeAsync();
}
@@ -0,0 +1,44 @@
<div class="exercise-editor">
<div class="exercise-heading">
<label class="grow">
Упражнение
<input @bind="Exercise.Name" @bind:event="oninput" @bind:after="Changed" placeholder="Название упражнения" />
</label>
<label class="camera-toggle" title="Нужно снять видео">
<input type="checkbox" @bind="Exercise.RequiresVideo" @bind:after="Changed" aria-label="Нужно снять видео" />
<span>📷</span>
</label>
</div>
<label class="video-input">
Ссылка на видео
<input type="url" @bind="Exercise.VideoUrl" @bind:event="oninput" @bind:after="Changed"
placeholder="https://disk.yandex.ru/…" />
</label>
<div class="exercise-fields">
<label>Повторения
<input @bind="Exercise.Repetitions" @bind:event="oninput" @bind:after="Changed" placeholder="10 или 10 (1 RIR)" />
</label>
<label>Вес, кг
<input @bind="Exercise.Weight" @bind:event="oninput" @bind:after="Changed" placeholder="60 или Подобрать" />
</label>
<label>От максимума, %
<input type="number" min="0" max="100" step="any" @bind="Exercise.IntensityPercent"
@bind:after="Changed" placeholder="—" />
</label>
</div>
<label>Разминочные подходы
<textarea rows="2" @bind="Exercise.WarmupSets" @bind:event="oninput" @bind:after="Changed"
placeholder="1 — гриф; 2 — 40 кг"></textarea>
</label>
<label>Примечание
<textarea rows="2" @bind="Exercise.Notes" @bind:event="oninput" @bind:after="Changed"
placeholder="Техника, темп или другие указания"></textarea>
</label>
</div>
@code {
[Parameter, EditorRequired] public WorkoutExercise Exercise { get; set; } = null!;
[Parameter] public EventCallback OnChanged { get; set; }
private Task Changed() => OnChanged.InvokeAsync();
}
@@ -0,0 +1,53 @@
<div class="exercise-picker grow" @ref="root">
@if (Groups.Count > 0)
{
<label>Группа упражнений
<select aria-label="Группа упражнений" @bind="groupId" @bind:after="SearchChangedAsync">
<option value="-1">Все группы</option>
<option value="0">Без группы</option>
@foreach (var group in Groups)
{
<option value="@group.Id">@group.Name</option>
}
</select>
</label>
}
<label for="@Id">Из библиотеки</label>
<div class="exercise-combobox">
<input id="@Id" type="search" role="combobox" aria-label="Поиск упражнения"
aria-autocomplete="list" aria-haspopup="listbox" aria-expanded="false"
aria-controls="@(Id + "-results")" aria-activedescendant="@ActiveOptionId"
@bind="search" @bind:event="oninput" @bind:after="SearchChangedAsync"
@onfocus="OpenAsync" @onclick="OpenAsync" @onkeydown="HandleKeyAsync" disabled="@applying"
placeholder="Выберите или найдите упражнение…" autocomplete="off" />
<button type="button" class="picker-toggle" aria-label="Раскрыть список упражнений"
popovertarget="@(Id + "-results")" disabled="@applying">⌄</button>
</div>
<div id="@(Id + "-results")" class="exercise-dropdown" popover="auto" role="listbox" aria-label="Упражнения из библиотеки">
@for (var index = 0; index < Matches.Count; index++)
{
var exercise = Matches[index];
var optionIndex = index;
<button @key="exercise.Id" id="@(Id + "-option-" + exercise.Id)" type="button" role="option" tabindex="-1"
class="exercise-option @(activeIndex == index ? "active" : "")"
aria-selected="@(Value == exercise.Id ? "true" : "false")"
@onmousedown:preventDefault @onclick="() => SelectAsync(exercise.Id, optionIndex)"
@ondblclick="() => ApplyAsync(exercise.Id)">
<strong>@exercise.Name</strong>
<span>@(exercise.Group?.Name ?? "Без группы")</span>
</button>
}
@if (Matches.Count == 0)
{
<p class="muted" role="status">Упражнения не найдены. Измените запрос.</p>
}
else
{
<p class="picker-hint">Двойной клик или Enter — добавить</p>
}
</div>
@if (SelectedExercise != null)
{
<p class="picker-selection" role="status">Выбрано: @SelectedExercise.Name</p>
}
</div>
@@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Web.Components.Editor;
public partial class ExercisePicker
{
[Inject] public IJSRuntime JavaScript { get; set; } = null!;
[Parameter, EditorRequired] public string Id { get; set; } = string.Empty;
[Parameter] public List<LibraryExercise> Exercises { get; set; } = [];
[Parameter] public int Value { get; set; }
[Parameter] public int ResetVersion { get; set; }
[Parameter] public EventCallback<int> ValueChanged { get; set; }
[Parameter] public EventCallback<int> OnApply { get; set; }
private ElementReference root;
private string search = string.Empty;
private int groupId = -1;
private int activeIndex = -1;
private bool applying;
private int lastResetVersion;
private bool closeAfterReset;
private List<LibraryExercise> Matches = [];
private LibraryExercise? SelectedExercise => Exercises.SingleOrDefault(exercise => exercise.Id == Value);
private string? ActiveOptionId => activeIndex >= 0 && activeIndex < Matches.Count
? Id + "-option-" + Matches[activeIndex].Id : null;
private List<ExerciseGroup> Groups => Exercises.Where(exercise => exercise.Group != null).Select(exercise => exercise.Group!)
.DistinctBy(group => group.Id).OrderBy(group => group.Name).ToList();
protected override void OnParametersSet()
{
if (lastResetVersion != ResetVersion)
{
lastResetVersion = ResetVersion;
search = string.Empty;
activeIndex = -1;
closeAfterReset = true;
}
if (groupId > 0 && Groups.All(group => group.Id != groupId)) groupId = -1;
UpdateMatches();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (closeAfterReset)
{
closeAfterReset = false;
await JavaScript.InvokeVoidAsync("trainingPlanner.closeExercisePicker", root);
}
await JavaScript.InvokeVoidAsync("trainingPlanner.positionExercisePicker", root);
}
private void UpdateMatches()
{
Matches = ExerciseSearch.Find(Exercises.Where(exercise => groupId < 0 ||
(groupId == 0 ? exercise.GroupId == null : exercise.GroupId == groupId)), search);
if (activeIndex >= Matches.Count) activeIndex = -1;
}
private async Task SearchChangedAsync()
{
activeIndex = -1;
UpdateMatches();
await ValueChanged.InvokeAsync(0);
await OpenAsync();
}
private async Task OpenAsync() => await JavaScript.InvokeVoidAsync("trainingPlanner.openExercisePicker", root);
private async Task SelectAsync(int id, int index)
{
activeIndex = index;
await ValueChanged.InvokeAsync(id);
}
private async Task ApplyAsync(int id)
{
if (applying || Matches.All(exercise => exercise.Id != id)) return;
applying = true;
try
{
await JavaScript.InvokeVoidAsync("trainingPlanner.closeExercisePicker", root);
await OnApply.InvokeAsync(id);
}
finally { applying = false; }
}
private async Task HandleKeyAsync(KeyboardEventArgs args)
{
if (applying) return;
if (args.Key is "ArrowDown" or "ArrowUp")
{
await OpenAsync();
if (Matches.Count == 0) return;
activeIndex = args.Key == "ArrowDown" ? Math.Min(activeIndex + 1, Matches.Count - 1)
: activeIndex < 0 ? Matches.Count - 1 : Math.Max(activeIndex - 1, 0);
}
else if (args.Key == "Enter" && await JavaScript.InvokeAsync<bool>("trainingPlanner.isExercisePickerOpen", root))
{
var index = activeIndex < 0 ? 0 : activeIndex;
if (index < Matches.Count) await ApplyAsync(Matches[index].Id);
}
}
}
@@ -0,0 +1,28 @@
@inherits LayoutComponentBase
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href="/">
<span class="brand-mark">TP</span>
<span>Training<br/>
<strong>Planner</strong>
</span>
</a>
<div class="nav-label">РАБОЧЕЕ ПРОСТРАНСТВО</div>
<nav>
<NavLink href="/" Match="NavLinkMatch.All">◎ <span>Клиенты</span>
</NavLink>
<NavLink href="/exercises">▤ <span>Библиотека упражнений</span>
</NavLink>
</nav>
<div class="sidebar-bottom">
<span class="muted">Кабинет тренера</span>
<form method="post" action="/Logout">
<AntiforgeryToken/>
<button class="text-button">Выйти ↗</button>
</form>
</div>
</aside>
<main class="main-content">@Body</main>
</div>
<div id="blazor-error-ui" data-nosnippet>Соединение прервано или произошла ошибка. <a href=".">Перезагрузить</a>
</div>
@@ -0,0 +1,35 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]">
</script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div>
</div>
<div>
</div>
</div>
<p class="components-reconnect-first-attempt-visible">
Восстанавливаем соединение…
</p>
<p class="components-reconnect-repeated-attempt-visible">
Следующая попытка через <span id="components-seconds-to-next-attempt">
</span> сек.
</p>
<p class="components-reconnect-failed-visible">
Не удалось восстановить соединение.<br />Попробуйте ещё раз или обновите страницу.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Повторить
</button>
<p class="components-pause-visible">
Сеанс приостановлен.
</p>
<p class="components-resume-failed-visible">
Не удалось продолжить сеанс.<br />Попробуйте ещё раз или обновите страницу.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Продолжить
</button>
</div>
</dialog>
@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
@@ -0,0 +1,79 @@
@inherits UiPageBase
@inject IExerciseGroupRepository Repository
@inject IJSRuntime JavaScript
<section class="panel compact group-panel">
<fieldset class="editor-fieldset" disabled="@(Busy || Disabled || !RendererInfo.IsInteractive)">
<div class="list-toolbar">
<h2>Группы упражнений</h2>
<button type="button" @onclick="NewGroup">+ Новая группа</button>
</div>
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
<div class="group-tabs" aria-label="Группы упражнений">
<button class="@(Value == -1 ? "selected" : "")" @onclick="() => ValueChanged.InvokeAsync(-1)">Все упражнения</button>
<button class="@(Value == 0 ? "selected" : "")" data-exercise-group="0" @ondragover:preventDefault @ondrop:preventDefault
@ondrop="() => DropExerciseAsync(0)" @onclick="() => ValueChanged.InvokeAsync(0)">Без группы</button>
@foreach (var group in Groups)
{
<button @key="group.Id" class="@(Value == group.Id ? "selected" : "")"
data-exercise-group="@group.Id" @ondragover:preventDefault @ondrop:preventDefault
@ondrop="() => DropExerciseAsync(group.Id)"
@onclick="() => ValueChanged.InvokeAsync(group.Id)">@group.Name</button>
}
</div>
@if (SelectedGroup != null)
{
<div class="actions group-actions">
<button @onclick="() => editing = new ExerciseGroup { Id = SelectedGroup.Id, Name = SelectedGroup.Name }">Переименовать группу</button>
<button class="danger-text" disabled="@(Busy || Disabled || !RendererInfo.IsInteractive)" @onclick="DeleteAsync">Удалить группу</button>
</div>
}
@if (editing != null)
{
<form class="group-form" @onsubmit="SaveAsync" @onsubmit:preventDefault>
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
<label>Название группы<input @bind="editing.Name" required /></label>
<div class="actions">
<button class="primary">Сохранить группу</button>
<button type="button" @onclick="() => editing = null">Отмена</button>
</div>
</fieldset>
</form>
}
</fieldset>
</section>
@code {
[Parameter] public List<ExerciseGroup> Groups { get; set; } = [];
[Parameter] public int Value { get; set; }
[Parameter] public EventCallback<int> ValueChanged { get; set; }
[Parameter] public EventCallback OnChanged { get; set; }
[Parameter] public EventCallback<int> OnDropExercise { get; set; }
[Parameter] public bool Disabled { get; set; }
private ExerciseGroup? editing;
private ExerciseGroup? SelectedGroup => Groups.SingleOrDefault(group => group.Id == Value);
private void NewGroup() => editing = new();
private Task DropExerciseAsync(int id) => Busy || Disabled ? Task.CompletedTask : OnDropExercise.InvokeAsync(id);
private Task SaveAsync() => RunAsync(async () =>
{
await Repository.SaveAsync(editing!);
var id = editing!.Id;
editing = null;
await OnChanged.InvokeAsync();
await ValueChanged.InvokeAsync(id);
});
private async Task DeleteAsync()
{
var group = SelectedGroup;
if (group == null || Busy || Disabled) return;
if (!await JavaScript.InvokeAsync<bool>("confirm",
$"Удалить группу «{group.Name}» вместе со всеми её упражнениями? Составленные тренировки сохранятся.")) return;
await RunAsync(async () =>
{
await Repository.DeleteAsync(group.Id);
editing = null;
await OnChanged.InvokeAsync();
await ValueChanged.InvokeAsync(-1);
});
}
}
@@ -0,0 +1,51 @@
@inherits UiPageBase
@implements IAsyncDisposable
<dialog @ref="dialog" class="exercise-import" aria-labelledby="import-title" @oncancel:preventDefault @oncancel="CloseAsync">
<div class="list-toolbar">
<h2 id="import-title">Импорт упражнений</h2>
<button type="button" class="icon-button" aria-label="Закрыть импорт" disabled="@Busy" @onclick="CloseAsync">×</button>
</div>
<p class="muted">Скопируйте из Excel два столбца без заголовков и вставьте в первую ячейку.
Строки добавятся автоматически. Ссылка необязательна; одинаковые упражнения создаются как отдельные записи.</p>
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
<p class="error clipboard-error" role="alert" hidden></p>
<fieldset disabled="@Busy">
<label>Группа для импорта
<select @bind="groupChoice">
<option value="">Без группы</option>
@foreach (var group in Groups) { <option value="@group.Id">@group.Name</option> }
<option value="new">Создать новую группу</option>
</select>
</label>
@if (groupChoice == "new")
{
<label>Название новой группы<input @bind="newGroupName" @bind:event="oninput" /></label>
}
<div class="import-table-scroll">
<table class="import-table">
<thead><tr><th>Наименование упражнения</th><th>Ссылка на видео</th><th><span class="muted">Удалить</span></th></tr></thead>
<tbody>
@for (var index = 0; index < rows.Count; index++)
{
var rowIndex = index;
var row = rows[index];
<tr @key="row">
<td><input aria-label="@($"Название, строка {index + 1}")" data-row="@index" data-column="0" @bind="row.Name" @bind:event="oninput" /></td>
<td><input aria-label="@($"Видео, строка {index + 1}")" data-row="@index" data-column="1" @bind="row.VideoUrl" @bind:event="oninput" /></td>
<td><button type="button" class="icon-button danger-text" aria-label="@($"Удалить строку {index + 1}")" @onclick="() => RemoveRow(rowIndex)">×</button></td>
</tr>
}
</tbody>
</table>
</div>
<div class="list-toolbar">
<button type="button" disabled="@(rows.Count >= Options.MaxRows)" @onclick="() => rows.Add(new())">Добавить строку</button>
<span class="muted">Упражнений: @FilledCount · максимум @Options.MaxRows</span>
</div>
<div class="actions">
<button type="button" class="primary" disabled="@(FilledCount == 0)" @onclick="ImportAsync">Подтвердить импорт</button>
<button type="button" @onclick="CloseAsync">Отмена</button>
</div>
</fieldset>
</dialog>
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Web.Components.Library;
public partial class ExerciseImport
{
[Inject] public IExerciseImporter Importer { get; set; } = null!;
[Inject] public ExerciseImportOptions Options { get; set; } = null!;
[Inject] public ClipboardTableParser Parser { get; set; } = null!;
[Inject] public IJSRuntime JavaScript { get; set; } = null!;
[Parameter] public List<ExerciseGroup> Groups { get; set; } = [];
[Parameter] public int? InitialGroupId { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback<int> OnImported { get; set; }
private ElementReference dialog;
private DotNetObjectReference<ExerciseImport>? reference;
private List<ExerciseImportRow> rows = [new()];
private string groupChoice = string.Empty;
private string newGroupName = string.Empty;
private int FilledCount => rows.Count(row => !string.IsNullOrWhiteSpace(row.Name) || !string.IsNullOrWhiteSpace(row.VideoUrl));
protected override void OnInitialized() => groupChoice = InitialGroupId?.ToString() ?? string.Empty;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
reference = DotNetObjectReference.Create(this);
await JavaScript.InvokeVoidAsync("exerciseImport.open", dialog, reference, Options.MaxClipboardCharacters);
}
[JSInvokable]
public async Task PasteAsync(string text, int rowIndex, int columnIndex)
{
await RunAsync(() =>
{
var pasted = Parser.Parse(text);
if (rowIndex < 0 || rowIndex >= rows.Count || columnIndex < 0 || columnIndex > 1)
throw new InvalidOperationException("Выберите ячейку для вставки.");
if (pasted.Any(row => row.Count + columnIndex > 2))
throw new InvalidOperationException("В таблице два столбца. Вставьте название и ссылку начиная с первого столбца.");
if (rowIndex + pasted.Count > Options.MaxRows)
throw new InvalidOperationException($"В таблице может быть не более {Options.MaxRows} строк.");
while (rows.Count < rowIndex + pasted.Count) rows.Add(new());
for (var index = 0; index < pasted.Count; index++)
{
var cells = pasted[index];
var row = rows[rowIndex + index];
if (columnIndex == 0) row.Name = cells[0];
else row.VideoUrl = cells[0];
if (cells.Count == 2) row.VideoUrl = cells[1];
}
return Task.CompletedTask;
});
await InvokeAsync(StateHasChanged);
}
private void RemoveRow(int index)
{
rows.RemoveAt(index);
if (rows.Count == 0) rows.Add(new());
}
private Task CloseAsync() => Busy ? Task.CompletedTask : OnClose.InvokeAsync();
private Task ImportAsync() => RunAsync(async () =>
{
if (groupChoice == "new") PlanValidator.RequireName(newGroupName, "Название новой группы");
var groupId = int.TryParse(groupChoice, out var id) ? id : (int?)null;
var count = await Importer.ImportAsync(rows, groupId, groupChoice == "new" ? newGroupName : string.Empty);
await OnImported.InvokeAsync(count);
});
public async ValueTask DisposeAsync()
{
try { await JavaScript.InvokeVoidAsync("exerciseImport.close", dialog); }
catch (JSDisconnectedException) { }
finally { reference?.Dispose(); }
}
}
@@ -0,0 +1,143 @@
@page "/clients/{Id:int}"
@attribute [Authorize]
@rendermode InteractiveServer
@inherits UiPageBase
@inject IClientRepository Clients
@inject IPlanRepository Plans
@inject PlanFactory Factory
@inject PlanCopier Copier
@inject NavigationManager Navigation
@inject IJSRuntime JavaScript
<PageTitle>Программы клиента · Training Planner</PageTitle>
<a class="breadcrumb" href="/">← Все клиенты</a>
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
@if (client != null)
{
<header class="page-heading">
<div>
<p class="eyebrow">КАРТОЧКА КЛИЕНТА</p>
<h1>@client.Name</h1>
</div>
<div class="actions">
<span class="count-badge">Программ: @plans.Count</span>
<button type="button" class="icon-button danger-text" title="Удалить клиента" aria-label="Удалить клиента"
disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="DeleteAsync">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
</svg>
</button>
</div>
</header>
<details class="panel compact">
<summary>Данные клиента</summary>
<form @onsubmit="SaveClientAsync" @onsubmit:preventDefault>
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
<label>Имя<input @bind="client.Name" required />
</label>
<label>Цель<textarea @bind="client.Goal" /></label>
<label>Режим тренировок<textarea @bind="client.TrainingSchedule" /></label>
<label>Состояние здоровья<textarea @bind="client.HealthStatus" /></label>
<label>Заметки<textarea @bind="client.Notes" /></label>
<button disabled="@Busy">Сохранить данные</button>
</fieldset>
</form>
</details>
<section class="panel compact">
<h2>Программа на месяц</h2>
<form @onsubmit="CreateAsync" @onsubmit:preventDefault>
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
<div class="inline-form">
<label class="grow">Название программы<input @bind="newName" placeholder="Например, октябрь · набор массы" required />
</label>
<button class="primary" disabled="@Busy">Создать программу</button>
</div>
</fieldset>
</form>
</section>
<h2 class="section-title">История программ</h2>
@if (plans.Count == 0) { <section class="empty-state">
<h2>Пока нет программ</h2>
<p class="muted">Создайте первую программу, а в следующем месяце используйте её как основу.</p>
</section> }
@foreach (var plan in plans)
{
<div class="program-row">
<div>
<a class="program-title" href="@($"/programs/{plan.Id}")">@plan.Name</a>
<p class="muted">Создана @plan.CreatedAtUtc.ToLocalTime().ToString("dd.MM.yyyy")</p>
</div>
<div class="actions">
<button disabled="@Busy" @onclick="() => BeginCopy(plan)">Создать на основе</button>
<a class="button" href="@($"/programs/{plan.Id}")">Открыть →</a>
<button type="button" class="icon-button danger-text" title="Удалить программу" aria-label="@($"Удалить программу «{plan.Name}»")"
disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="() => DeletePlanAsync(plan)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
</svg>
</button>
</div>
</div>
}
@if (copySource != null)
{
<section class="panel compact">
<h2>Копия: @copySource.Name</h2>
<form @onsubmit="CopyAsync" @onsubmit:preventDefault>
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
<label>Новое название<input @bind="copyName" required />
</label>
<div class="actions">
<button class="primary" disabled="@Busy">Создать независимую копию</button>
<button type="button" @onclick="() => copySource = null">Отмена</button>
</div>
</fieldset>
</form>
</section>
}
}
@code {
[Parameter] public int Id { get; set; }
private Client? client;
private List<TrainingPlan> plans = [];
private string newName = string.Empty;
private string copyName = string.Empty;
private TrainingPlan? copySource;
protected override Task OnParametersSetAsync() => RunAsync(async () =>
{
client = await Clients.GetAsync(Id);
if (client == null) throw new InvalidOperationException("Клиент не найден.");
plans = await Plans.GetForClientAsync(Id);
});
private Task SaveClientAsync() => RunAsync(() => Clients.SaveAsync(client!));
private Task DeleteAsync() => RunAsync(async () =>
{
if (!await JavaScript.InvokeAsync<bool>("confirm",
$"Удалить клиента «{client!.Name}» и все его программы тренировок? Это действие нельзя отменить.")) return;
await Clients.DeleteAsync(Id);
Navigation.NavigateTo("/");
});
private Task CreateAsync() => RunAsync(async () =>
{
var plan = Factory.Create(Id, newName);
await Plans.SaveAsync(plan);
Navigation.NavigateTo($"/programs/{plan.Id}");
});
private void BeginCopy(TrainingPlan plan) { copySource = plan; copyName = plan.Name; }
private Task DeletePlanAsync(TrainingPlan plan) => RunAsync(async () =>
{
if (!await JavaScript.InvokeAsync<bool>("confirm",
$"Удалить программу «{plan.Name}» со всеми её неделями и тренировками? Это действие нельзя отменить.")) return;
await Plans.DeleteAsync(plan.Id);
plans.Remove(plan);
if (copySource?.Id == plan.Id) copySource = null;
});
private Task CopyAsync() => RunAsync(async () =>
{
var original = await Plans.GetAsync(copySource!.Id) ?? throw new InvalidOperationException("Программа не найдена.");
var copy = Copier.Copy(original, copyName);
await Plans.SaveAsync(copy);
Navigation.NavigateTo($"/programs/{copy.Id}");
});
}
@@ -0,0 +1,5 @@
@page "/Error"
<PageTitle>Ошибка · Training Planner</PageTitle>
<h1>Не удалось открыть страницу</h1>
<p class="muted">Попробуйте обновить страницу. Если ошибка повторяется, вернитесь к списку клиентов.</p>
<a class="button" href="/">К клиентам</a>
@@ -0,0 +1,81 @@
@page "/exercises"
@attribute [Authorize]
@rendermode InteractiveServer
@inherits UiPageBase
<PageTitle>Библиотека упражнений · Training Planner</PageTitle>
<header class="page-heading">
<div>
<p class="eyebrow">БИБЛИОТЕКА</p>
<h1>Упражнения</h1>
<p class="muted">Название и ссылка на видео — для быстрого добавления в программу.</p>
</div>
<div class="actions">
<span class="count-badge">Упражнений: @items.Count</span>
<button disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="() => importOpen = true">Импорт из таблицы</button>
</div>
</header>
@if (importOpen)
{
<ExerciseImport Groups="groups" InitialGroupId="@(selectedGroupId > 0 ? selectedGroupId : null)"
OnClose="() => importOpen = false" OnImported="ImportedAsync" />
}
@if (!string.IsNullOrEmpty(importMessage)) { <p role="status">@importMessage</p> }
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
<ExerciseGroups Groups="groups" Value="selectedGroupId" ValueChanged="SelectGroup" OnChanged="ReloadAsync"
OnDropExercise="MoveToGroupAsync" Disabled="Busy" />
<p class="muted">Перетащите упражнение на название группы или на «Без группы», чтобы перенести его.</p>
<section class="panel compact">
<h2>@(editing.Id == 0 ? "Новое упражнение" : "Редактирование упражнения")</h2>
<form @onsubmit="SaveAsync" @onsubmit:preventDefault>
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
<div class="form-grid">
<label>Название<input @bind="editing.Name" required />
</label>
<label>Ссылка на видео<input type="url" @bind="editing.VideoUrl" placeholder="https://disk.yandex.ru/…" />
</label>
</div>
<label>Группа
<select @bind="editing.GroupId">
<option value="">Без группы</option>
@foreach (var group in groups) { <option value="@group.Id">@group.Name</option> }
</select>
</label>
<div class="actions">
<button class="primary" disabled="@Busy">Сохранить упражнение</button>@if (editing.Id != 0) { <button type="button" @onclick="ResetEditor">Отмена</button> }</div>
</fieldset>
</form>
</section>
<div class="list-toolbar">
<h2>@ListTitle</h2>
<input class="search" @bind="search" @bind:event="oninput" placeholder="Найти упражнение…" aria-label="Поиск упражнений" />
</div>
@if (selectedGroupId > 0)
{
<details class="panel compact">
<summary>Добавить копию упражнения из библиотеки</summary>
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)" class="add-exercise-bar">
<ExercisePicker ResetVersion="pickerResetVersion" Id="group-exercise-picker" @key="selectedGroupId" Exercises="AvailableExercises" @bind-Value="existingExerciseId" OnApply="ApplyExistingAsync" />
<button disabled="@(existingExerciseId == 0)" @onclick="AddExistingAsync">Добавить копию в группу</button>
</fieldset>
</details>
}
@foreach (var exercise in FilteredExercises)
{
<div class="program-row library-exercise" @key="exercise.Id" draggable="@(!Busy && RendererInfo.IsInteractive ? "true" : "false")"
@ondragstart="() => draggedExerciseId = exercise.Id" @ondragend="() => draggedExerciseId = null">
<div><strong>@exercise.Name</strong><p class="muted">@(exercise.Group?.Name ?? "Без группы")</p></div>
<div class="actions">@if (!string.IsNullOrEmpty(exercise.VideoUrl)) { <a href="@exercise.VideoUrl" target="_blank" rel="noopener noreferrer">Видео ↗</a> }<button disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="() => Edit(exercise)">Изменить</button>
<button class="icon-button danger-text" title="Удалить из библиотеки" aria-label="@($"Удалить упражнение {exercise.Name}")"
disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="() => DeleteAsync(exercise)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
</svg>
</button>
</div>
</div>
}
@if (FilteredExercises.Count == 0)
{
<p class="muted">В этом списке пока нет упражнений, подходящих под запрос.</p>
}
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Web.Components.Pages;
public partial class Exercises
{
[Inject] public IExerciseRepository ExerciseLibrary { get; set; } = null!;
[Inject] public IExerciseGroupRepository GroupRepository { get; set; } = null!;
[Inject] public IJSRuntime JavaScript { get; set; } = null!;
private List<LibraryExercise> items = [];
private List<ExerciseGroup> groups = [];
private LibraryExercise editing = new();
private string search = string.Empty;
private int selectedGroupId = -1;
private int existingExerciseId;
private int pickerResetVersion;
private int? draggedExerciseId;
private bool importOpen;
private string importMessage = string.Empty;
private Task ImportedAsync(int count)
{
importOpen = false;
importMessage = $"Импортировано упражнений: {count}.";
SelectGroup(-1);
return RunAsync(ReloadAsync);
}
private List<LibraryExercise> AvailableExercises => items.Where(item => item.GroupId != selectedGroupId).ToList();
private string ListTitle => selectedGroupId < 0 ? "Все упражнения" : selectedGroupId == 0 ? "Без группы" :
groups.SingleOrDefault(group => group.Id == selectedGroupId)?.Name ?? "Группа";
private List<LibraryExercise> FilteredExercises => ExerciseSearch.Find(items.Where(item =>
selectedGroupId < 0 || (selectedGroupId == 0 ? item.GroupId == null : item.GroupId == selectedGroupId)), search);
protected override Task OnInitializedAsync() => RunAsync(ReloadAsync);
private async Task ReloadAsync()
{
items = await ExerciseLibrary.GetAllAsync();
groups = await GroupRepository.GetAllAsync();
if (selectedGroupId > 0 && groups.All(group => group.Id != selectedGroupId)) selectedGroupId = -1;
if (editing.Id != 0 && items.All(item => item.Id != editing.Id)) ResetEditor();
if (editing.GroupId.HasValue && groups.All(group => group.Id != editing.GroupId)) editing.GroupId = null;
}
private void SelectGroup(int id)
{
selectedGroupId = id;
existingExerciseId = 0;
search = string.Empty;
if (editing.Id == 0) ResetEditor();
}
private void ResetEditor() => editing = new LibraryExercise
{
GroupId = selectedGroupId > 0 ? selectedGroupId : null
};
private void Edit(LibraryExercise exercise) => editing = new LibraryExercise
{
Id = exercise.Id, Name = exercise.Name, VideoUrl = exercise.VideoUrl, GroupId = exercise.GroupId
};
private Task SaveAsync() => RunAsync(async () =>
{
await ExerciseLibrary.SaveAsync(editing);
await ReloadAsync();
ResetEditor();
});
private Task ApplyExistingAsync(int id)
{
existingExerciseId = id;
return AddExistingAsync();
}
private Task AddExistingAsync() => RunAsync(async () =>
{
await GroupRepository.AddExerciseAsync(selectedGroupId, existingExerciseId);
existingExerciseId = 0;
pickerResetVersion++;
await ReloadAsync();
});
private async Task MoveToGroupAsync(int targetGroupId)
{
var id = draggedExerciseId;
draggedExerciseId = null;
if (id == null || Busy || targetGroupId < 0) return;
int? groupId = targetGroupId == 0 ? null : targetGroupId;
await RunAsync(async () =>
{
await ExerciseLibrary.MoveAsync(id.Value, groupId);
if (editing.Id == id.Value) editing.GroupId = groupId;
await ReloadAsync();
});
}
private async Task DeleteAsync(LibraryExercise exercise)
{
if (Busy) return;
if (!await JavaScript.InvokeAsync<bool>("confirm",
$"Удалить «{exercise.Name}» из библиотеки? В составленных тренировках упражнение сохранится.")) return;
await RunAsync(async () =>
{
await ExerciseLibrary.DeleteAsync(exercise.Id);
if (editing.Id == exercise.Id) ResetEditor();
await ReloadAsync();
});
}
}
@@ -0,0 +1,51 @@
@page "/"
@attribute [Authorize]
@rendermode InteractiveServer
@inherits UiPageBase
@inject IClientRepository Clients
<PageTitle>Клиенты · Training Planner</PageTitle>
<header class="page-heading">
<div>
<p class="eyebrow">РАБОЧЕЕ ПРОСТРАНСТВО</p>
<h1>Клиенты</h1>
<p class="muted">Персональные программы и история тренировок.</p>
</div>
<span class="count-badge">Клиентов: @items.Count</span>
</header>
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
<section class="panel compact">
<h2>Новый клиент</h2>
<form @onsubmit="CreateAsync" @onsubmit:preventDefault>
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
<div class="inline-form">
<label class="grow">Имя клиента<input @bind="name" placeholder="Имя и фамилия" required />
</label>
<button class="primary" disabled="@Busy">Добавить клиента</button>
</div>
</fieldset>
</form>
</section>
@if (items.Count == 0) { <section class="empty-state">
<span class="empty-symbol">◎</span>
<h2>Начните с первого клиента</h2>
<p class="muted">После добавления клиента можно создать его программу на месяц.</p>
</section> }
else { <div class="client-grid">@foreach (var client in items) { <a class="client-card" href="@($"/clients/{client.Id}")">
<span class="avatar">@client.Name[..1].ToUpperInvariant()</span>
<div>
<h2>@client.Name</h2>
<p class="muted">Открыть программы</p>
</div>
<span class="arrow">↗</span>
</a> }</div> }
@code {
private List<Client> items = [];
private string name = string.Empty;
protected override Task OnInitializedAsync() => RunAsync(async () => items = await Clients.GetAllAsync());
private Task CreateAsync() => RunAsync(async () =>
{
await Clients.SaveAsync(new Client { Name = name });
name = string.Empty;
items = await Clients.GetAllAsync();
});
}
@@ -0,0 +1,4 @@
@page "/not-found"
<PageTitle>Страница не найдена · Training Planner</PageTitle>
<h1>Страница не найдена</h1>
<a class="button" href="/">К клиентам</a>
@@ -0,0 +1,51 @@
namespace TrainingPlanner.Web.Components.Pages;
public partial class ProgramEditor
{
private CancellationTokenSource? pendingUndoExpiry;
private Task DeleteBlockAsync(int index) => ModifyAsync(() =>
{
ClearDeletedBlock();
deletedFrom = CurrentWorkout;
deletedIndex = index;
deletedBlock = CurrentWorkout!.Blocks[index];
CurrentWorkout.Blocks.RemoveAt(index);
pendingUndoExpiry = new CancellationTokenSource();
_ = ExpireUndoAsync(pendingUndoExpiry.Token);
});
private Task UndoDeleteAsync()
{
if (deletedBlock == null || deletedFrom != CurrentWorkout) return Task.CompletedTask;
return ModifyAsync(() =>
{
deletedFrom!.Blocks.Insert(Math.Min(deletedIndex, deletedFrom.Blocks.Count), deletedBlock);
ClearDeletedBlock();
});
}
private async Task ExpireUndoAsync(CancellationToken cancellation)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(Options.UndoDeleteDurationSeconds), cancellation);
await InvokeAsync(() =>
{
if (disposed || cancellation.IsCancellationRequested) return;
ClearDeletedBlock();
StateHasChanged();
});
}
catch (OperationCanceledException) { }
}
private void ClearDeletedBlock()
{
pendingUndoExpiry?.Cancel();
pendingUndoExpiry?.Dispose();
pendingUndoExpiry = null;
deletedBlock = null;
deletedFrom = null;
}
}
@@ -0,0 +1,71 @@
using TrainingPlanner.Domain;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
namespace TrainingPlanner.Web.Components.Pages;
public partial class ProgramEditor
{
private TrainingWeek? draggedWeek;
private async Task DeleteWeekAsync()
{
if (plan == null || Busy || plan.Weeks.Count <= 1) return;
var week = CurrentWeek;
var confirmed = await JavaScript.InvokeAsync<bool>("confirm",
$"Удалить неделю {weekIndex + 1} и все её тренировки?");
if (!confirmed || Busy || plan.Weeks.Count <= 1 || !plan.Weeks.Contains(week)) return;
await ModifyAsync(() =>
{
var index = plan.Weeks.IndexOf(week);
plan.Weeks.RemoveAt(index);
for (var position = 0; position < plan.Weeks.Count; position++)
plan.Weeks[position].Position = position;
SelectWeek(Math.Min(index, plan.Weeks.Count - 1));
draggedWeek = null;
dragIndex = -1;
if (deletedFrom != null && week.Workouts.Contains(deletedFrom))
{
ClearDeletedBlock();
}
});
}
private void StartWeekDrag(int index)
{
draggedWorkout = null;
draggedWeek = plan!.Weeks[index];
dragIndex = -1;
}
private async Task DropWeekAsync(int targetIndex, DragEventArgs args)
{
var week = draggedWeek;
draggedWeek = null;
if (plan == null || week == null || Busy)
return;
var sourceIndex = plan.Weeks.IndexOf(week);
if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= plan.Weeks.Count)
return;
var insertionIndex = await JavaScript.InvokeAsync<int>("trainingPlanner.weekInsertionIndex", targetIndex, args.ClientX);
if (insertionIndex < 0 || insertionIndex > plan.Weeks.Count) return;
if (sourceIndex < insertionIndex) insertionIndex--;
if (sourceIndex == insertionIndex) return;
await ModifyAsync(() =>
{
var selectedWeek = CurrentWeek;
plan.Weeks.RemoveAt(sourceIndex);
plan.Weeks.Insert(insertionIndex, week);
for (var index = 0; index < plan.Weeks.Count; index++)
plan.Weeks[index].Position = index;
weekIndex = plan.Weeks.IndexOf(selectedWeek);
});
}
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Web.Components.Pages;
public partial class ProgramEditor
{
private Workout? draggedWorkout;
private void StartWorkoutDrag(int index)
{
draggedWorkout = CurrentWeek.Workouts[index];
draggedWeek = null;
dragIndex = -1;
}
private async Task DropWorkoutAsync(int targetIndex, DragEventArgs args)
{
var workout = draggedWorkout;
draggedWorkout = null;
if (plan == null || workout == null || Busy) return;
var week = CurrentWeek;
var sourceIndex = week.Workouts.IndexOf(workout);
if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= week.Workouts.Count) return;
var insertionIndex = await JavaScript.InvokeAsync<int>(
"trainingPlanner.workoutInsertionIndex", targetIndex, args.ClientY);
if (Busy || CurrentWeek != week || insertionIndex < 0 || insertionIndex > week.Workouts.Count) return;
if (sourceIndex < insertionIndex) insertionIndex--;
if (sourceIndex == insertionIndex) return;
await ModifyAsync(() =>
{
var selectedWorkout = CurrentWorkout;
week.Workouts.RemoveAt(sourceIndex);
week.Workouts.Insert(insertionIndex, workout);
for (var index = 0; index < week.Workouts.Count; index++)
week.Workouts[index].Position = index;
workoutIndex = week.Workouts.IndexOf(selectedWorkout!);
});
}
}
@@ -0,0 +1,33 @@
using Microsoft.JSInterop;
namespace TrainingPlanner.Web.Components.Pages;
public partial class ProgramEditor
{
private async Task DeleteWorkoutAsync()
{
if (plan == null || Busy || CurrentWorkout == null) return;
var week = CurrentWeek;
var workout = CurrentWorkout;
var confirmed = await JavaScript.InvokeAsync<bool>("confirm",
$"Удалить тренировку «{workout.Name}» и все её упражнения?");
if (!confirmed || Busy || !plan.Weeks.Contains(week) || !week.Workouts.Contains(workout)) return;
await ModifyAsync(() =>
{
var index = week.Workouts.IndexOf(workout);
week.Workouts.RemoveAt(index);
for (var position = 0; position < week.Workouts.Count; position++)
week.Workouts[position].Position = position;
weekIndex = plan.Weeks.IndexOf(week);
workoutIndex = Math.Max(0, Math.Min(index, week.Workouts.Count - 1));
dragIndex = -1;
if (deletedFrom == workout)
{
ClearDeletedBlock();
}
});
}
}
@@ -0,0 +1,149 @@
@page "/programs/{Id:int}"
@attribute [Authorize]
@rendermode InteractiveServer
@inherits UiPageBase
@implements IDisposable
<PageTitle>@(plan?.Name ?? "Программа") · Training Planner</PageTitle>
<NavigationLock ConfirmExternalNavigation="dirty" OnBeforeInternalNavigation="BeforeNavigation" />
@if (plan != null)
{
<a class="breadcrumb" href="@($"/clients/{plan.ClientId}")">← @clientName · Все программы</a>
<header class="page-heading editor-heading">
<div>
<p class="eyebrow">ПРОГРАММА НА МЕСЯЦ</p>
<h1>
<input class="title-input" disabled="@(Busy || !RendererInfo.IsInteractive)"
aria-label="Название программы" @bind="plan.Name"
@bind:event="oninput" @bind:after="ChangedAsync" />
</h1>
</div>
<div class="actions">
<span class="save-status" role="status">
@(Busy ? "Сохраняем…" : dirty ? "Есть несохранённые изменения" : "✓ Сохранено")
</span>
<button disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="SaveAsync">Сохранить</button>
<button class="primary" disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="ExportAsync">
↓ Скачать PDF
</button>
</div>
</header>
}
@if (!string.IsNullOrEmpty(Error))
{
<div class="error" role="alert">@Error</div>
}
@if (plan != null)
{
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)" class="editor-fieldset">
<div class="week-tabs" role="tablist" aria-label="Недели программы">
@for (var index = 0; index < plan.Weeks.Count; index++)
{
var selectedIndex = index;
<button @key="plan.Weeks[index]" role="tab" aria-selected="@(weekIndex == index ? "true" : "false")"
data-week-index="@index"
class="@(weekIndex == index ? "selected" : "")"
draggable="true" title="Перетащите, чтобы изменить порядок недель"
@ondragstart="() => StartWeekDrag(selectedIndex)"
@ondragend="() => draggedWeek = null"
@ondragover:preventDefault
@ondrop:preventDefault
@ondrop="args => DropWeekAsync(selectedIndex, args)"
@onclick="() => SelectWeek(selectedIndex)">
Неделя @(index + 1)
<span>Тренировок: @plan.Weeks[index].Workouts.Count</span>
</button>
}
<button class="add-week" @onclick="AddWeekAsync">+ Неделя</button>
</div>
<div class="program-workspace">
<aside class="workout-nav">
<div class="list-toolbar">
<h2>Тренировки</h2>
<button class="icon-button" @onclick="AddWorkoutAsync" aria-label="Добавить тренировку">+</button>
</div>
@for (var index = 0; index < CurrentWeek.Workouts.Count; index++)
{
var selectedIndex = index;
<button @key="CurrentWeek.Workouts[index]" class="workout-tab @(workoutIndex == index ? "selected" : "")"
data-workout-index="@index" draggable="true"
title="Перетащите, чтобы изменить порядок тренировок"
@ondragstart="() => StartWorkoutDrag(selectedIndex)"
@ondragend="() => draggedWorkout = null"
@ondragover:preventDefault @ondrop:preventDefault
@ondrop="args => DropWorkoutAsync(selectedIndex, args)"
@onclick="() => workoutIndex = selectedIndex">
<span>@((index + 1).ToString("00"))</span>
<strong>@CurrentWeek.Workouts[index].Name</strong>
</button>
}
<button class="text-button" @onclick="CopyWeekAsync">Копировать неделю</button>
<button class="text-button danger-text" disabled="@(plan.Weeks.Count == 1)"
title="@(plan.Weeks.Count == 1 ? "В программе должна остаться хотя бы одна неделя" : "Удалить выбранную неделю вместе с тренировками")"
@onclick="DeleteWeekAsync">Удалить неделю</button>
</aside>
<section class="workout-editor">
@if (CurrentWorkout == null)
{
<div class="empty-state">
<span class="empty-symbol">+</span>
<h2>Первая тренировка недели</h2>
<p class="muted">Добавьте тренировку, затем выберите упражнения.</p>
<button class="primary" @onclick="AddWorkoutAsync">Добавить тренировку</button>
</div>
}
else
{
<div class="workout-heading">
<input aria-label="Название тренировки" @bind="CurrentWorkout.Name"
@bind:event="oninput" @bind:after="ChangedAsync" />
<button @onclick="CopyWorkoutAsync">Копировать тренировку</button>
<button class="icon-button danger-text" title="Удалить тренировку"
aria-label="Удалить тренировку" @onclick="DeleteWorkoutAsync">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
</svg>
</button>
</div>
<div class="workout-summary">
<p class="muted">Общий отдых: @WorkoutTotals.RestMinutes(CurrentWorkout).ToString("0.##") мин</p>
<label>Время тренировки
<input @bind="CurrentWorkout.Duration" @bind:event="oninput" @bind:after="ChangedAsync"
placeholder="Например, 1 ч 15 мин" />
</label>
</div>
@if (deletedBlock != null && deletedFrom == CurrentWorkout)
{
<div class="undo-message">
Блок удалён.
<button class="text-button" @onclick="UndoDeleteAsync">Отменить</button>
</div>
}
@for (var index = 0; index < CurrentWorkout.Blocks.Count; index++)
{
var blockIndex = index;
var block = CurrentWorkout.Blocks[index];
<div @key="block" @ondragover:preventDefault @ondrop="() => DropAsync(blockIndex)">
<BlockCard Block="block" Number="@(index + 1)"
IsFirst="@(index == 0)" IsLast="@(index == CurrentWorkout.Blocks.Count - 1)"
OnChanged="ChangedAsync" OnMove="direction => MoveAsync(blockIndex, direction)"
OnCopy="() => CopyBlockAsync(blockIndex)" OnDelete="() => DeleteBlockAsync(blockIndex)"
OnDrag="() => { draggedWorkout = null; draggedWeek = null; dragIndex = blockIndex; }" />
</div>
}
<div class="add-exercise-bar">
<ExercisePicker ResetVersion="pickerResetVersion" Id="workout-exercise-picker" Exercises="library" @bind-Value="libraryId" OnApply="ApplyLibraryExerciseAsync" />
<button disabled="@(libraryId == 0)" @onclick="AddFromLibraryAsync">Добавить</button>
<button class="primary" @onclick="AddManualAsync">+ Ввести вручную</button>
</div>
}
</section>
</div>
</fieldset>
}
@@ -0,0 +1,147 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.JSInterop;
using TrainingPlanner.Application;
using TrainingPlanner.Domain;
namespace TrainingPlanner.Web.Components.Pages;
public partial class ProgramEditor
{
[Parameter] public int Id { get; set; }
[Inject] public IPlanRepository Plans { get; set; } = null!;
[Inject] public IClientRepository Clients { get; set; } = null!;
[Inject] public IExerciseRepository Exercises { get; set; } = null!;
[Inject] public PlanFactory Factory { get; set; } = null!;
[Inject] public PlanCopier Copier { get; set; } = null!;
[Inject] public BlockEditor Blocks { get; set; } = null!;
[Inject] public PlannerOptions Options { get; set; } = null!;
[Inject] public NavigationManager Navigation { get; set; } = null!;
[Inject] public IJSRuntime JavaScript { get; set; } = null!;
private TrainingPlan? plan;
private List<LibraryExercise> library = [];
private string clientName = string.Empty;
private int weekIndex;
private int workoutIndex;
private int libraryId;
private int pickerResetVersion;
private int dragIndex = -1;
private bool dirty;
private bool disposed;
private int editVersion;
private CancellationTokenSource? pendingSave;
private WorkoutBlock? deletedBlock;
private Workout? deletedFrom;
private int deletedIndex;
private TrainingWeek CurrentWeek => plan!.Weeks[weekIndex];
private Workout? CurrentWorkout => CurrentWeek.Workouts.ElementAtOrDefault(workoutIndex);
protected override Task OnParametersSetAsync() => RunAsync(async () =>
{
pendingSave?.Cancel();
ClearDeletedBlock();
plan = await Plans.GetAsync(Id) ?? throw new InvalidOperationException("Программа не найдена.");
clientName = (await Clients.GetAsync(plan.ClientId))?.Name ?? string.Empty;
library = await Exercises.GetAllAsync();
weekIndex = 0;
workoutIndex = 0;
draggedWeek = null;
draggedWorkout = null;
dragIndex = -1;
dirty = false;
});
private async Task ChangedAsync()
{
dirty = true;
editVersion++;
pendingSave?.Cancel();
pendingSave?.Dispose();
var cancellation = new CancellationTokenSource();
pendingSave = cancellation;
try
{
await Task.Delay(Options.AutosaveDelayMilliseconds, cancellation.Token);
if (!disposed) await SaveAsync();
}
catch (OperationCanceledException) { }
}
private Task SaveAsync()
{
pendingSave?.Cancel();
return RunAsync(async () =>
{
var savedVersion = editVersion;
var snapshot = Copier.Copy(plan!, plan!.Name);
snapshot.Id = plan.Id;
snapshot.Version = plan.Version;
await Plans.SaveAsync(snapshot);
plan.Version = snapshot.Version;
plan.CreatedAtUtc = snapshot.CreatedAtUtc;
plan.UpdatedAtUtc = snapshot.UpdatedAtUtc;
dirty = savedVersion != editVersion;
});
}
private async Task ExportAsync()
{
await SaveAsync();
if (!dirty && string.IsNullOrEmpty(Error))
await JavaScript.InvokeVoidAsync("trainingPlanner.download", $"/programs/{Id}/pdf");
}
private async Task ModifyAsync(Action action)
{
try { action(); Error = string.Empty; }
catch (InvalidOperationException exception) { Error = exception.Message; return; }
await ChangedAsync();
}
private void SelectWeek(int index) { weekIndex = index; workoutIndex = 0; draggedWorkout = null; dragIndex = -1; }
private Task AddWeekAsync() => ModifyAsync(() =>
{
plan!.Weeks.Add(new TrainingWeek());
SelectWeek(plan.Weeks.Count - 1);
});
private Task CopyWeekAsync() => ModifyAsync(() =>
{
plan!.Weeks.Add(Copier.CopyWeek(CurrentWeek));
SelectWeek(plan.Weeks.Count - 1);
});
private Task AddWorkoutAsync() => ModifyAsync(() =>
{
CurrentWeek.Workouts.Add(new Workout
{
Name = $"{Options.DefaultWorkoutName} {CurrentWeek.Workouts.Count + 1}",
Duration = Options.DefaultWorkoutDuration
});
workoutIndex = CurrentWeek.Workouts.Count - 1;
});
private Task CopyWorkoutAsync() => ModifyAsync(() =>
{
CurrentWeek.Workouts.Add(Copier.CopyWorkout(CurrentWorkout!));
workoutIndex = CurrentWeek.Workouts.Count - 1;
});
private Task AddManualAsync() => ModifyAsync(() => CurrentWorkout!.Blocks.Add(Factory.CreateBlock()));
private Task ApplyLibraryExerciseAsync(int id)
{
libraryId = id;
return AddFromLibraryAsync();
}
private Task AddFromLibraryAsync() => ModifyAsync(() =>
{
CurrentWorkout!.Blocks.Add(Factory.CreateBlock(library.Single(exercise => exercise.Id == libraryId)));
libraryId = 0;
pickerResetVersion++;
});
private Task MoveAsync(int index, int direction) => ModifyAsync(() => Blocks.Move(CurrentWorkout!, index, index + direction));
private Task DropAsync(int index) => ModifyAsync(() => { Blocks.Move(CurrentWorkout!, dragIndex, index); dragIndex = -1; });
private Task CopyBlockAsync(int index) => ModifyAsync(() => CurrentWorkout!.Blocks.Insert(index + 1, Copier.CopyBlock(CurrentWorkout.Blocks[index])));
private async Task BeforeNavigation(LocationChangingContext context)
{
if (dirty && !await JavaScript.InvokeAsync<bool>("confirm", "Есть несохранённые изменения. Покинуть программу?"))
context.PreventNavigation();
}
public void Dispose() { disposed = true; ClearDeletedBlock(); pendingSave?.Cancel(); pendingSave?.Dispose(); }
}
@@ -0,0 +1,4 @@
@inject NavigationManager Navigation
@code {
protected override void OnInitialized() => Navigation.NavigateTo("/Login", forceLoad: true);
}
@@ -0,0 +1,10 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Components;
namespace TrainingPlanner.Web.Components;
public class UiPageBase : ComponentBase
{
[Inject] public ILogger<UiPageBase> Logger { get; set; } = null!;
protected string Error { get; set; } = string.Empty;
protected bool Busy { get; set; }
protected async Task RunAsync(Func<Task> action)
{
if (Busy) return;
Busy = true;
Error = string.Empty;
try { await action(); }
catch (InvalidOperationException exception) { Error = exception.Message; }
catch (Exception exception)
{
Logger.LogError(exception, "Operation failed");
Error = "Не удалось выполнить действие. Изменения остались на экране. Попробуйте ещё раз.";
}
finally { Busy = false; }
}
}
@@ -0,0 +1,19 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using TrainingPlanner.Web
@using TrainingPlanner.Web.Components
@using TrainingPlanner.Web.Components.Layout
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using TrainingPlanner.Application
@using TrainingPlanner.Domain
@using TrainingPlanner.Web.Components.Editor
@using TrainingPlanner.Web.Components.Library
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using TrainingPlanner.Application;
using TrainingPlanner.Infrastructure.Data;
using TrainingPlanner.Infrastructure.Pdf;
using TrainingPlanner.Web.Security;
namespace TrainingPlanner.Web.Configuration;
public static class ServiceRegistration
{
public static void AddTrainingPlanner(this IServiceCollection services, IConfiguration config)
{
services.AddOptions<ExerciseImportOptions>().Bind(config.GetSection("ExerciseImport"))
.Validate(options => options.MaxRows > 0 && options.MaxClipboardCharacters > 0,
"Проверьте настройки ExerciseImport.").ValidateOnStart();
services.AddSingleton(provider => provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<ExerciseImportOptions>>().Value);
services.AddSingleton<ClipboardTableParser>();
services.AddSingleton<ExerciseImportValidator>();
services.AddScoped<IExerciseImporter, ExerciseImporter>();
services.AddOptions<PlannerOptions>().Bind(config.GetSection("Planner"))
.Validate(options => options.DefaultWeekCount > 0 && options.DefaultSetsCount > 0
&& options.DefaultRestMinutes >= 0 && options.AutosaveDelayMilliseconds > 0 && options.UndoDeleteDurationSeconds > 0
&& options.DefaultIntensityPercent is >= 0 and <= 100
&& !string.IsNullOrWhiteSpace(options.DefaultWorkoutDuration),
"Проверьте настройки Planner.").ValidateOnStart();
services.AddSingleton(provider => provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<PlannerOptions>>().Value);
services.AddDbContextFactory<TrainingDbContext>(options => options.UseNpgsql(
config.GetConnectionString("TrainingDatabase"), database => database.MigrationsAssembly("TrainingPlanner.Web")));
services.AddIdentity<IdentityUser, IdentityRole>(options => config.GetSection("Identity").Bind(options))
.AddEntityFrameworkStores<TrainingDbContext>().AddDefaultTokenProviders();
services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/Login";
options.AccessDeniedPath = "/Login";
options.ExpireTimeSpan = TimeSpan.FromHours(config.GetValue<double>("Security:CookieHours"));
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = config.GetValue<bool>("Security:RequireHttps")
? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
});
var keyPath = config["Security:DataProtectionKeysPath"];
if (!string.IsNullOrWhiteSpace(keyPath))
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(keyPath));
services.AddCascadingAuthenticationState();
services.AddScoped<AuthenticationStateProvider, TrainerAuthenticationStateProvider>();
services.AddScoped<ITrainerContext, TrainerContext>();
services.AddScoped<IClientRepository, ClientRepository>();
services.AddScoped<IExerciseRepository, ExerciseRepository>();
services.AddScoped<IExerciseGroupRepository, ExerciseGroupRepository>();
services.AddScoped<IPlanRepository, PlanRepository>();
services.AddScoped<IPlanReader, PlanReader>();
services.AddScoped<DatabaseInitializer>();
services.AddSingleton<PlanFactory>();
services.AddSingleton<PlanCopier>();
services.AddSingleton<PlanValidator>();
services.AddSingleton<BlockEditor>();
services.AddSingleton<IPlanPdfExporter, PlanPdfExporter>();
services.AddSingleton(config.GetSection("Pdf").Get<PdfOptions>() ?? new PdfOptions());
}
}
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using TrainingPlanner.Infrastructure.Data;
namespace TrainingPlanner.Web.Data;
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<TrainingDbContext>
{
public TrainingDbContext CreateDbContext(string[] args)
{
var builder = new DbContextOptionsBuilder<TrainingDbContext>();
builder.UseNpgsql(options => options.MigrationsAssembly(typeof(Program).Assembly.GetName().Name));
return new TrainingDbContext(builder.Options);
}
}
@@ -0,0 +1,602 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TrainingPlanner.Infrastructure.Data;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
[DbContext(typeof(TrainingDbContext))]
[Migration("20260924164145_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("Clients");
});
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseSet", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<string>("Repetitions")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("WeightKg")
.HasColumnType("numeric");
b.Property<int>("WorkoutExerciseId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutExerciseId");
b.ToTable("ExerciseSet");
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("LibraryExercises");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ClientId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("TrainerId");
b.ToTable("TrainingPlans");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingPlanId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingPlanId");
b.ToTable("TrainingWeek");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingWeekId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingWeekId");
b.ToTable("Workout");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("RestSeconds")
.HasColumnType("integer");
b.Property<int>("WorkoutId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutId");
b.ToTable("WorkoutBlock");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<bool>("RequiresVideo")
.HasColumnType("boolean");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.Property<int>("WorkoutBlockId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutBlockId");
b.ToTable("WorkoutExercise");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseSet", b =>
{
b.HasOne("TrainingPlanner.Domain.WorkoutExercise", null)
.WithMany("Sets")
.HasForeignKey("WorkoutExerciseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.HasOne("TrainingPlanner.Domain.Client", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
.WithMany("Weeks")
.HasForeignKey("TrainingPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
.WithMany("Workouts")
.HasForeignKey("TrainingWeekId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.HasOne("TrainingPlanner.Domain.Workout", null)
.WithMany("Blocks")
.HasForeignKey("WorkoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
.WithMany("Exercises")
.HasForeignKey("WorkoutBlockId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Navigation("Weeks");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Navigation("Workouts");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Navigation("Blocks");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Navigation("Exercises");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.Navigation("Sets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,471 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
RoleId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Clients",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TrainerId = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Notes = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Clients", x => x.Id);
table.ForeignKey(
name: "FK_Clients_AspNetUsers_TrainerId",
column: x => x.TrainerId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "LibraryExercises",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TrainerId = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
VideoUrl = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LibraryExercises", x => x.Id);
table.ForeignKey(
name: "FK_LibraryExercises_AspNetUsers_TrainerId",
column: x => x.TrainerId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "TrainingPlans",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ClientId = table.Column<int>(type: "integer", nullable: false),
TrainerId = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Version = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TrainingPlans", x => x.Id);
table.ForeignKey(
name: "FK_TrainingPlans_AspNetUsers_TrainerId",
column: x => x.TrainerId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TrainingPlans_Clients_ClientId",
column: x => x.ClientId,
principalTable: "Clients",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "TrainingWeek",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TrainingPlanId = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TrainingWeek", x => x.Id);
table.ForeignKey(
name: "FK_TrainingWeek_TrainingPlans_TrainingPlanId",
column: x => x.TrainingPlanId,
principalTable: "TrainingPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Workout",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TrainingWeekId = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Workout", x => x.Id);
table.ForeignKey(
name: "FK_Workout_TrainingWeek_TrainingWeekId",
column: x => x.TrainingWeekId,
principalTable: "TrainingWeek",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "WorkoutBlock",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
WorkoutId = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
RestSeconds = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkoutBlock", x => x.Id);
table.ForeignKey(
name: "FK_WorkoutBlock_Workout_WorkoutId",
column: x => x.WorkoutId,
principalTable: "Workout",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "WorkoutExercise",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
WorkoutBlockId = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
VideoUrl = table.Column<string>(type: "text", nullable: false),
RequiresVideo = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkoutExercise", x => x.Id);
table.ForeignKey(
name: "FK_WorkoutExercise_WorkoutBlock_WorkoutBlockId",
column: x => x.WorkoutBlockId,
principalTable: "WorkoutBlock",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExerciseSet",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
WorkoutExerciseId = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Repetitions = table.Column<string>(type: "text", nullable: false),
WeightKg = table.Column<decimal>(type: "numeric", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExerciseSet", x => x.Id);
table.ForeignKey(
name: "FK_ExerciseSet_WorkoutExercise_WorkoutExerciseId",
column: x => x.WorkoutExerciseId,
principalTable: "WorkoutExercise",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Clients_TrainerId",
table: "Clients",
column: "TrainerId");
migrationBuilder.CreateIndex(
name: "IX_ExerciseSet_WorkoutExerciseId",
table: "ExerciseSet",
column: "WorkoutExerciseId");
migrationBuilder.CreateIndex(
name: "IX_LibraryExercises_TrainerId",
table: "LibraryExercises",
column: "TrainerId");
migrationBuilder.CreateIndex(
name: "IX_TrainingPlans_ClientId",
table: "TrainingPlans",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_TrainingPlans_TrainerId",
table: "TrainingPlans",
column: "TrainerId");
migrationBuilder.CreateIndex(
name: "IX_TrainingWeek_TrainingPlanId",
table: "TrainingWeek",
column: "TrainingPlanId");
migrationBuilder.CreateIndex(
name: "IX_Workout_TrainingWeekId",
table: "Workout",
column: "TrainingWeekId");
migrationBuilder.CreateIndex(
name: "IX_WorkoutBlock_WorkoutId",
table: "WorkoutBlock",
column: "WorkoutId");
migrationBuilder.CreateIndex(
name: "IX_WorkoutExercise_WorkoutBlockId",
table: "WorkoutExercise",
column: "WorkoutBlockId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "ExerciseSet");
migrationBuilder.DropTable(
name: "LibraryExercises");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "WorkoutExercise");
migrationBuilder.DropTable(
name: "WorkoutBlock");
migrationBuilder.DropTable(
name: "Workout");
migrationBuilder.DropTable(
name: "TrainingWeek");
migrationBuilder.DropTable(
name: "TrainingPlans");
migrationBuilder.DropTable(
name: "Clients");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}
@@ -0,0 +1,586 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TrainingPlanner.Infrastructure.Data;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
[DbContext(typeof(TrainingDbContext))]
[Migration("20260924224103_ExercisePrescription")]
partial class ExercisePrescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("Clients");
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("LibraryExercises");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ClientId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("TrainerId");
b.ToTable("TrainingPlans");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingPlanId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingPlanId");
b.ToTable("TrainingWeek");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Duration")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingWeekId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingWeekId");
b.ToTable("Workout");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<decimal>("RestMinutes")
.HasColumnType("numeric");
b.Property<int>("SetsCount")
.HasColumnType("integer");
b.Property<int>("WorkoutId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutId");
b.ToTable("WorkoutBlock");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal?>("IntensityPercent")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<string>("Repetitions")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("RequiresVideo")
.HasColumnType("boolean");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("WarmupSets")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Weight")
.IsRequired()
.HasColumnType("text");
b.Property<int>("WorkoutBlockId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutBlockId");
b.ToTable("WorkoutExercise");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.HasOne("TrainingPlanner.Domain.Client", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
.WithMany("Weeks")
.HasForeignKey("TrainingPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
.WithMany("Workouts")
.HasForeignKey("TrainingWeekId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.HasOne("TrainingPlanner.Domain.Workout", null)
.WithMany("Blocks")
.HasForeignKey("WorkoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
.WithMany("Exercises")
.HasForeignKey("WorkoutBlockId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Navigation("Weeks");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Navigation("Workouts");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Navigation("Blocks");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Navigation("Exercises");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,122 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
/// <inheritdoc />
public partial class ExercisePrescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SetsCount", table: "WorkoutBlock", type: "integer", nullable: false, defaultValue: 0);
migrationBuilder.AddColumn<decimal>(
name: "IntensityPercent",
table: "WorkoutExercise",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "WorkoutExercise",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Repetitions",
table: "WorkoutExercise",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "WarmupSets",
table: "WorkoutExercise",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Weight",
table: "WorkoutExercise",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<decimal>(
name: "RestMinutes",
table: "WorkoutBlock",
type: "numeric",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "Duration",
table: "Workout",
type: "text",
nullable: false,
defaultValue: "");
// Preserve the first approach as the shared prescription before removing the old rows.
migrationBuilder.Sql("""
UPDATE "WorkoutExercise" AS exercise
SET "Repetitions" = COALESCE((
SELECT "Repetitions" FROM "ExerciseSet"
WHERE "WorkoutExerciseId" = exercise."Id" ORDER BY "Position", "Id" LIMIT 1), ''),
"Weight" = COALESCE((
SELECT replace("WeightKg"::text, '.', ',') FROM "ExerciseSet"
WHERE "WorkoutExerciseId" = exercise."Id" ORDER BY "Position", "Id" LIMIT 1), '');
UPDATE "WorkoutBlock" SET "RestMinutes" = "RestSeconds" / 60.0;
DO $$
DECLARE
item RECORD;
target_block integer;
previous_block integer := NULL;
previous_workout integer := NULL;
block_position integer := 0;
BEGIN
FOR item IN
SELECT b."Id" AS block_id, b."WorkoutId" AS workout_id,
b."RestMinutes" AS rest_minutes, b."RestSeconds" AS rest_seconds,
e."Id" AS exercise_id,
(SELECT count(*) FROM "ExerciseSet" s WHERE s."WorkoutExerciseId" = e."Id")::integer AS sets_count
FROM "WorkoutBlock" b JOIN "WorkoutExercise" e ON e."WorkoutBlockId" = b."Id"
ORDER BY b."WorkoutId", b."Position", b."Id", e."Position", e."Id"
LOOP
IF previous_workout IS DISTINCT FROM item.workout_id THEN
block_position := 0;
END IF;
IF previous_block IS DISTINCT FROM item.block_id THEN
target_block := item.block_id;
UPDATE "WorkoutBlock" SET "Position" = block_position,
"SetsCount" = GREATEST(1, item.sets_count) WHERE "Id" = target_block;
ELSE
INSERT INTO "WorkoutBlock" ("WorkoutId", "Position", "RestSeconds", "RestMinutes", "SetsCount")
VALUES (item.workout_id, block_position, item.rest_seconds, item.rest_minutes, GREATEST(1, item.sets_count))
RETURNING "Id" INTO target_block;
END IF;
UPDATE "WorkoutExercise" SET "WorkoutBlockId" = target_block, "Position" = 0
WHERE "Id" = item.exercise_id;
previous_block := item.block_id;
previous_workout := item.workout_id;
block_position := block_position + 1;
END LOOP;
END $$;
""");
migrationBuilder.DropTable(name: "ExerciseSet");
migrationBuilder.DropColumn(name: "RestSeconds", table: "WorkoutBlock");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
throw new NotSupportedException(
"Автоматический откат не восстанавливает индивидуальные подходы. Восстановите резервную копию БД вместе с предыдущей версией приложения.");
}
}
}
@@ -0,0 +1,630 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TrainingPlanner.Infrastructure.Data;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
[DbContext(typeof(TrainingDbContext))]
[Migration("20260925193904_ExerciseGroups")]
partial class ExerciseGroups
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("Clients");
});
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("ExerciseGroups");
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("GroupId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("TrainerId");
b.ToTable("LibraryExercises");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ClientId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("TrainerId");
b.ToTable("TrainingPlans");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingPlanId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingPlanId");
b.ToTable("TrainingWeek");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Duration")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingWeekId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingWeekId");
b.ToTable("Workout");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<decimal>("RestMinutes")
.HasColumnType("numeric");
b.Property<int>("SetsCount")
.HasColumnType("integer");
b.Property<int>("WorkoutId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutId");
b.ToTable("WorkoutBlock");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal?>("IntensityPercent")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<string>("Repetitions")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("RequiresVideo")
.HasColumnType("boolean");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("WarmupSets")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Weight")
.IsRequired()
.HasColumnType("text");
b.Property<int>("WorkoutBlockId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutBlockId");
b.ToTable("WorkoutExercise");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.HasOne("TrainingPlanner.Domain.ExerciseGroup", "Group")
.WithMany()
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Group");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.HasOne("TrainingPlanner.Domain.Client", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
.WithMany("Weeks")
.HasForeignKey("TrainingPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
.WithMany("Workouts")
.HasForeignKey("TrainingWeekId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.HasOne("TrainingPlanner.Domain.Workout", null)
.WithMany("Blocks")
.HasForeignKey("WorkoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
.WithMany("Exercises")
.HasForeignKey("WorkoutBlockId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Navigation("Weeks");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Navigation("Workouts");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Navigation("Blocks");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Navigation("Exercises");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
/// <inheritdoc />
public partial class ExerciseGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "GroupId",
table: "LibraryExercises",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "ExerciseGroups",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TrainerId = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExerciseGroups", x => x.Id);
table.ForeignKey(
name: "FK_ExerciseGroups_AspNetUsers_TrainerId",
column: x => x.TrainerId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_LibraryExercises_GroupId",
table: "LibraryExercises",
column: "GroupId");
migrationBuilder.CreateIndex(
name: "IX_ExerciseGroups_TrainerId",
table: "ExerciseGroups",
column: "TrainerId");
migrationBuilder.AddForeignKey(
name: "FK_LibraryExercises_ExerciseGroups_GroupId",
table: "LibraryExercises",
column: "GroupId",
principalTable: "ExerciseGroups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_LibraryExercises_ExerciseGroups_GroupId",
table: "LibraryExercises");
migrationBuilder.DropTable(
name: "ExerciseGroups");
migrationBuilder.DropIndex(
name: "IX_LibraryExercises_GroupId",
table: "LibraryExercises");
migrationBuilder.DropColumn(
name: "GroupId",
table: "LibraryExercises");
}
}
}
@@ -0,0 +1,642 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TrainingPlanner.Infrastructure.Data;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
[DbContext(typeof(TrainingDbContext))]
[Migration("20260929161544_ClientProfile")]
partial class ClientProfile
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Goal")
.IsRequired()
.HasColumnType("text");
b.Property<string>("HealthStatus")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainingSchedule")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("Clients");
});
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("ExerciseGroups");
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("GroupId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("TrainerId");
b.ToTable("LibraryExercises");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ClientId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("TrainerId");
b.ToTable("TrainingPlans");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingPlanId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingPlanId");
b.ToTable("TrainingWeek");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Duration")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingWeekId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingWeekId");
b.ToTable("Workout");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<decimal>("RestMinutes")
.HasColumnType("numeric");
b.Property<int>("SetsCount")
.HasColumnType("integer");
b.Property<int>("WorkoutId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutId");
b.ToTable("WorkoutBlock");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal?>("IntensityPercent")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<string>("Repetitions")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("RequiresVideo")
.HasColumnType("boolean");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("WarmupSets")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Weight")
.IsRequired()
.HasColumnType("text");
b.Property<int>("WorkoutBlockId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutBlockId");
b.ToTable("WorkoutExercise");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.HasOne("TrainingPlanner.Domain.ExerciseGroup", "Group")
.WithMany()
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Group");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.HasOne("TrainingPlanner.Domain.Client", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
.WithMany("Weeks")
.HasForeignKey("TrainingPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
.WithMany("Workouts")
.HasForeignKey("TrainingWeekId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.HasOne("TrainingPlanner.Domain.Workout", null)
.WithMany("Blocks")
.HasForeignKey("WorkoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
.WithMany("Exercises")
.HasForeignKey("WorkoutBlockId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Navigation("Weeks");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Navigation("Workouts");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Navigation("Blocks");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Navigation("Exercises");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
/// <inheritdoc />
public partial class ClientProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Goal",
table: "Clients",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "HealthStatus",
table: "Clients",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TrainingSchedule",
table: "Clients",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Goal",
table: "Clients");
migrationBuilder.DropColumn(
name: "HealthStatus",
table: "Clients");
migrationBuilder.DropColumn(
name: "TrainingSchedule",
table: "Clients");
}
}
}
@@ -0,0 +1,639 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TrainingPlanner.Infrastructure.Data;
#nullable disable
namespace TrainingPlanner.Web.Data.Migrations
{
[DbContext(typeof(TrainingDbContext))]
partial class TrainingDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Goal")
.IsRequired()
.HasColumnType("text");
b.Property<string>("HealthStatus")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainingSchedule")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("Clients");
});
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("TrainerId");
b.ToTable("ExerciseGroups");
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("GroupId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("TrainerId");
b.ToTable("LibraryExercises");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ClientId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TrainerId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClientId");
b.HasIndex("TrainerId");
b.ToTable("TrainingPlans");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingPlanId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingPlanId");
b.ToTable("TrainingWeek");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Duration")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("TrainingWeekId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TrainingWeekId");
b.ToTable("Workout");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<decimal>("RestMinutes")
.HasColumnType("numeric");
b.Property<int>("SetsCount")
.HasColumnType("integer");
b.Property<int>("WorkoutId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutId");
b.ToTable("WorkoutBlock");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal?>("IntensityPercent")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<string>("Repetitions")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("RequiresVideo")
.HasColumnType("boolean");
b.Property<string>("VideoUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("WarmupSets")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Weight")
.IsRequired()
.HasColumnType("text");
b.Property<int>("WorkoutBlockId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WorkoutBlockId");
b.ToTable("WorkoutExercise");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
{
b.HasOne("TrainingPlanner.Domain.ExerciseGroup", "Group")
.WithMany()
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Group");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.HasOne("TrainingPlanner.Domain.Client", null)
.WithMany()
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("TrainerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
.WithMany("Weeks")
.HasForeignKey("TrainingPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
.WithMany("Workouts")
.HasForeignKey("TrainingWeekId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.HasOne("TrainingPlanner.Domain.Workout", null)
.WithMany("Blocks")
.HasForeignKey("WorkoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
{
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
.WithMany("Exercises")
.HasForeignKey("WorkoutBlockId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
{
b.Navigation("Weeks");
});
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
{
b.Navigation("Workouts");
});
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
{
b.Navigation("Blocks");
});
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
{
b.Navigation("Exercises");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,2 @@
@page "/programs/{id:int}/pdf"
@model TrainingPlanner.Web.Pages.ExportModel
@@ -0,0 +1,23 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TrainingPlanner.Application;
namespace TrainingPlanner.Web.Pages;
[Authorize]
public class ExportModel(IPlanReader reader, IPlanPdfExporter exporter) : PageModel
{
public async Task<IActionResult> OnGetAsync(int id)
{
var trainerId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (trainerId == null) return Challenge();
var plan = await reader.GetAsync(id, trainerId);
if (plan == null) return NotFound();
var client = await reader.GetClientAsync(plan.ClientId, trainerId);
if (client == null) return NotFound();
Response.Headers.CacheControl = "no-store";
return File(exporter.Export(plan, client), "application/pdf", $"training-program-{id}.pdf");
}
}
@@ -0,0 +1,9 @@
@page
@model TrainingPlanner.Web.Pages.LoginModel
<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="icon" href="/favicon.svg"/><title>Вход · Training Planner</title><link rel="stylesheet" href="/app.css"/></head>
<body class="login-page"><main class="login-card"><div class="brand-mark">TP</div><p class="eyebrow">РАБОЧЕЕ МЕСТО ТРЕНЕРА</p><h1>С возвращением</h1><p class="muted">Войдите, чтобы продолжить работу с программами.</p>
<form method="post"><div asp-validation-summary="All" class="error" role="alert"></div>
<label>Логин<input asp-for="UserName" autocomplete="username" required autofocus/></label>
<label>Пароль<input asp-for="Password" type="password" autocomplete="current-password" required/></label>
<button class="primary" type="submit">Войти →</button></form></main></body></html>
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace TrainingPlanner.Web.Pages;
[AllowAnonymous]
public class LoginModel(SignInManager<IdentityUser> signIn) : PageModel
{
[BindProperty] public string UserName { get; set; } = string.Empty;
[BindProperty] public string Password { get; set; } = string.Empty;
public IActionResult OnGet() => User.Identity?.IsAuthenticated == true ? LocalRedirect("/") : Page();
public async Task<IActionResult> OnPostAsync(string? returnUrl)
{
if (!ModelState.IsValid) return Page();
var result = await signIn.PasswordSignInAsync(UserName, Password, false, lockoutOnFailure: true);
if (result.Succeeded)
return LocalRedirect(Url.IsLocalUrl(returnUrl) ? returnUrl! : "/");
ModelState.AddModelError(string.Empty, result.IsLockedOut
? "Слишком много попыток. Попробуйте позже." : "Не удалось войти. Проверьте логин и пароль.");
return Page();
}
}
@@ -0,0 +1,2 @@
@page
@model TrainingPlanner.Web.Pages.LogoutModel
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace TrainingPlanner.Web.Pages;
[Authorize]
public class LogoutModel(SignInManager<IdentityUser> signIn) : PageModel
{
public IActionResult OnGet() => LocalRedirect("/");
public async Task<IActionResult> OnPostAsync()
{
await signIn.SignOutAsync();
return LocalRedirect("/Login");
}
}
@@ -0,0 +1 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+38
View File
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.HttpOverrides;
using TrainingPlanner.Infrastructure.Data;
using TrainingPlanner.Web.Components;
using TrainingPlanner.Web.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents().AddInteractiveServerComponents()
.AddHubOptions(options => options.MaximumReceiveMessageSize =
builder.Configuration.GetValue<long>("InteractiveServer:MaximumReceiveMessageBytes"));
builder.Services.AddRazorPages();
builder.Services.AddTrainingPlanner(builder.Configuration);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (var address in builder.Configuration.GetSection("Security:KnownProxies").Get<string[]>() ?? [])
options.KnownProxies.Add(System.Net.IPAddress.Parse(address));
});
var app = builder.Build();
app.UseForwardedHeaders();
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler("/Error", createScopeForErrors: true);
if (builder.Configuration.GetValue<bool>("Security:RequireHttps"))
{
app.UseHsts();
app.UseHttpsRedirection();
}
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorPages();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
if (builder.Configuration.GetValue<bool>("Database:InitializeOnStartup"))
{
using var scope = app.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>().InitializeAsync();
}
app.Run();
@@ -0,0 +1,25 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
namespace TrainingPlanner.Web.Security;
public class TrainerAuthenticationStateProvider(ILoggerFactory loggerFactory,
IServiceScopeFactory scopes, IConfiguration configuration)
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
{
protected override TimeSpan RevalidationInterval =>
TimeSpan.FromMinutes(configuration.GetValue<double>("Security:RevalidationMinutes"));
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
using var scope = scopes.CreateScope();
var users = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var user = await users.GetUserAsync(authenticationState.User);
if (user == null) return false;
var stamp = authenticationState.User.FindFirstValue(new IdentityOptions().ClaimsIdentity.SecurityStampClaimType);
return stamp == await users.GetSecurityStampAsync(user);
}
}
@@ -0,0 +1,15 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using TrainingPlanner.Application;
namespace TrainingPlanner.Web.Security;
public class TrainerContext(AuthenticationStateProvider stateProvider) : ITrainerContext
{
public async Task<string> GetTrainerIdAsync()
{
var state = await stateProvider.GetAuthenticationStateAsync();
return state.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException("Войдите в приложение.");
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\TrainingPlanner.Infrastructure\TrainingPlanner.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Docker restores project files before Razor sources are copied. -->
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
<NoDefaultLaunchSettingsFile>True</NoDefaultLaunchSettingsFile>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
</Project>
@@ -0,0 +1,11 @@
{
"Security": {
"RequireHttps": false
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"TrainingDatabase": ""
},
"Planner": {
"DefaultWorkoutDuration": "1 час",
"DefaultIntensityPercent": 80,
"DefaultWeekCount": 4,
"DefaultSetsCount": 3,
"DefaultRepetitions": "10",
"DefaultWorkoutName": "Тренировка",
"UndoDeleteDurationSeconds": 10,
"AutosaveDelayMilliseconds": 1200,
"DefaultRestMinutes": 2
},
"BootstrapTrainer": {
"UserName": "test",
"Password": "test"
},
"Database": {
"InitializeOnStartup": true
},
"Security": {
"RequireHttps": true,
"CookieHours": 8,
"RevalidationMinutes": 5,
"DataProtectionKeysPath": "",
"KnownProxies": []
},
"Identity": {
"Password": {
"RequiredLength": 12,
"RequireDigit": true,
"RequireLowercase": true,
"RequireUppercase": true,
"RequireNonAlphanumeric": true
},
"Lockout": {
"DefaultLockoutTimeSpan": "00:15:00",
"MaxFailedAccessAttempts": 5,
"AllowedForNewUsers": true
}
},
"ExerciseImport": {
"MaxRows": 1000,
"MaxClipboardCharacters": 100000
},
"InteractiveServer": {
"MaximumReceiveMessageBytes": 1048576
},
"Pdf": {
"ClientDetailsGapLines": 5,
"ClientLabelWidthCentimeters": 4.5,
"FontPath": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"BoldFontPath": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"FontFamily": "PlannerSans",
"FontSize": 8,
"CameraSizePoints": 18,
"HeadingSize": 16,
"MarginCentimeters": 1.0,
"AccentColor": "#6654CB",
"HeaderBackground": "#E1E3E7",
"BorderColor": "#E0E3EB",
"ColumnWidthsCentimeters": [
0.86,
4.42,
3.24,
3.24,
1.73,
2.37,
2.37,
2.16,
1.19,
1.94,
1.51,
2.47
],
"WeekBackground": "#FFF15B",
"WorkoutBackground": "#A7EB75"
}
}
+788
View File
@@ -0,0 +1,788 @@
:root {
font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
color:#202637;
background:#f5f6fa;
font-synthesis:none;
--accent:#6654cb;
--line:#e5e7ef;
--muted:#737b8d
}
* {
box-sizing:border-box
}
body {
margin:0
}
button,input,select,textarea {
font:inherit
}
button,a,input,select,textarea {
outline-offset:4px
}
a {
color:var(--accent);
text-decoration:none
}
button,.button {
cursor:pointer;
border:1px solid var(--line);
border-radius:9px;
background:white;
padding:10px 15px;
color:#42495b;
font-size:13px;
font-weight:600;
display:inline-flex;
align-items:center;
justify-content:center;
white-space:nowrap;
gap:6px
}
button:hover,.button:hover {
background:#eeecfb;
border-color:#c9c0ef
}
button:disabled {
cursor:default;
opacity:.45
}
button.primary,.button.primary {
background:var(--accent);
border-color:var(--accent);
color:white
}
.primary:hover {
background:#5745b8
}
.text-button {
border:0;
background:transparent;
color:var(--accent);
padding:8px 0
}
.danger-text {
color:#b24558
}
.muted {
color:var(--muted);
font-size:13px;
line-height:1.6;
margin:7px 0
}
.eyebrow {
font-size:10px;
letter-spacing:1.7px;
font-weight:700;
color:var(--muted);
margin:0 0 12px
}
h1 {
font-size:32px;
letter-spacing:-1px;
margin:0 0 8px;
font-weight:700
}
h2 {
font-size:17px;
margin:0 0 16px;
letter-spacing:-.3px
}
label {
display:block;
font-size:12px;
font-weight:600;
color:#697184
}
input,select,textarea {
display:block;
width:100%;
border:1px solid #dfe3ed;
background:#fff;
color:#202637;
padding:10px 12px;
border-radius:7px;
font-size:14px;
margin-top:7px
}
input:focus,select:focus,textarea:focus {
border-color:var(--accent);
outline:2px solid #e7e2ff
}
textarea {
min-height:90px;
resize:vertical
}
form>label {
margin:18px 0
}
fieldset {
min-width:0;
border:0;
padding:0;
margin:0
}
input[type=checkbox] {
width:18px;
height:18px;
accent-color:var(--accent)
}
.app-shell {
display:flex;
min-height:100vh
}
.sidebar {
background:#1b2234;
color:#eff0f7;
width:235px;
flex-shrink:0;
padding:34px 20px;
display:flex;
flex-direction:column;
position:sticky;
top:0;
height:100vh
}
.brand {
display:flex;
align-items:center;
gap:13px;
color:#fff;
line-height:1.4;
font-size:14px;
padding:0 10px
}
.brand-mark {
display:flex;
align-items:center;
justify-content:center;
width:42px;
height:42px;
border-radius:12px;
background:#8070e3;
color:#fff;
font-size:15px;
font-weight:800;
letter-spacing:-1px
}
.nav-label {
font-size:9px;
color:#8993ac;
letter-spacing:1.2px;
margin:50px 10px 16px
}
.sidebar nav {
display:grid;
gap:8px
}
.sidebar nav a {
color:#aeb6c9;
display:flex;
align-items:center;
gap:12px;
padding:14px 12px;
border-radius:9px;
font-size:13px
}
.sidebar nav a.active {
background:#33314d;
color:#ddd6ff
}
.sidebar nav a:hover {
background:#283148
}
.sidebar-bottom {
margin-top:auto;
border-top:1px solid #31394c;
padding:20px 10px 0
}
.sidebar-bottom .text-button {
color:#c5bddf
}
.main-content {
flex:1;
min-width:0;
padding:38px 42px;
max-width:1750px
}
.page-heading {
display:flex;
justify-content:space-between;
align-items:center;
gap:20px;
margin:0 0 30px
}
.count-badge {
background:#eeebfb;
color:var(--accent);
border-radius:30px;
padding:9px 14px;
font-size:12px;
font-weight:600
}
.panel {
border:1px solid var(--line);
background:white;
border-radius:13px
}
.compact {
padding:23px 25px;
margin-bottom:24px
}
.inline-form {
display:flex;
align-items:end;
gap:16px
}
.grow {
flex:1
}
.actions {
display:flex;
align-items:center;
gap:10px
}
.client-grid {
display:grid;
grid-template-columns:repeat(auto-fill,minmax(280px,1fr));
gap:18px;
margin-top:25px
}
.client-card {
display:flex;
align-items:center;
gap:17px;
border:1px solid var(--line);
background:#fff;
border-radius:12px;
padding:25px;
color:inherit;
transition:box-shadow .15s
}
.client-card:hover {
box-shadow:0 6px 20px #22223b0a;
border-color:#c8beed
}
.client-card h2 {
margin:0;
font-size:16px
}
.client-card .arrow {
margin-left:auto;
color:var(--accent)
}
.avatar {
display:grid;
place-items:center;
background:#f0edff;
color:var(--accent);
font-size:20px;
width:50px;
height:50px;
border-radius:15px
}
.empty-state {
text-align:center;
padding:70px 20px;
border:1px dashed #d4d8e6;
border-radius:14px;
margin:24px 0;
background:#ffffff80
}
.empty-symbol {
display:block;
font-size:35px;
color:#9e91da;
margin-bottom:20px
}
.empty-state h2 {
margin-bottom:10px
}
.empty-state button {
margin-top:18px
}
.breadcrumb {
display:inline-block;
color:var(--muted);
font-size:12px;
margin-bottom:24px
}
.section-title {
margin:30px 0 18px
}
.program-row {
display:flex;
align-items:center;
justify-content:space-between;
gap:20px;
background:white;
border:1px solid var(--line);
border-radius:10px;
padding:20px 24px;
margin-bottom:12px
}
.program-title {
font-size:17px;
font-weight:650;
color:#30384b
}
.program-row .muted {
margin:8px 0 0;
font-size:11px
}
summary {
cursor:pointer;
font-size:14px;
font-weight:600
}
.form-grid {
display:grid;
grid-template-columns:1fr 1fr;
gap:20px;
margin-bottom:20px
}
.list-toolbar {
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
margin:25px 0 18px
}
.list-toolbar h2 {
margin:0
}
.search {
max-width:320px;
margin:0
}
.title-input {
font-size:28px;
font-weight:700;
background:transparent;
border:0;
padding:0;
letter-spacing:-.7px;
margin:0
}
.editor-heading {
align-items:center;
margin-bottom:30px
}
.editor-heading > div:first-child {
flex: 1;
min-width: 0;
}
.editor-heading > .actions {
flex-shrink: 0;
}
.save-status {
font-size:11px;
color:#7b8497;
max-width:130px
}
.week-tabs {
display:flex;
gap:4px;
border-bottom:1px solid var(--line);
margin-bottom:26px
}
.week-tabs button {
display:block;
background:transparent;
border:0;
border-bottom:3px solid transparent;
border-radius:0;
padding:16px 25px;
text-align:left;
font-size:14px
}
.week-tabs button span {
display:block;
font-size:10px;
color:var(--muted);
margin-top:7px;
font-weight:400
}
.week-tabs button.selected {
border-color:var(--accent);
color:var(--accent);
background:#eeebfa
}
.week-tabs button[draggable="true"] { cursor:grab; position:relative; }
.week-tabs button[draggable="true"]:active { cursor:grabbing; }
.week-tabs .week-dragging { opacity:.35; }
.week-tabs .week-drop-before::before,
.week-tabs .week-drop-after::after {
content:"";
position:absolute;
top:8px;
bottom:8px;
width:3px;
border-radius:3px;
background:var(--accent);
box-shadow:0 0 0 3px #eeebfa;
pointer-events:none
}
.week-tabs .week-drop-before::before { left:-4px; }
.week-tabs .week-drop-after::after { right:-4px; }
.week-drag-ghost {
position:fixed;
top:-1000px;
left:0;
width:72px;
height:72px;
box-sizing:border-box;
display:grid;
place-items:center;
padding:8px;
border:2px dashed var(--accent);
border-radius:8px;
background:transparent;
color:var(--accent);
opacity:.75;
font-family:inherit;
font-size:14px;
font-weight:600;
pointer-events:none
}
.week-tabs .add-week {
color:var(--accent);
font-size:12px
}
.program-workspace {
display:grid;
grid-template-columns:185px minmax(0,1fr);
gap:25px
}
.workout-nav {
border-right:1px solid var(--line);
padding-right:20px
}
.workout-nav .list-toolbar {
margin:0 0 20px
}
.workout-nav h2 {
font-size:13px
}
.icon-button {
padding:3px 8px
}
.workout-tab {
display:flex;
align-items:center;
justify-content:start;
gap:10px;
width:100%;
margin-bottom:8px;
text-align:left;
white-space:normal;
padding:14px 10px;
background:transparent;
border-color:transparent;
font-size:12px
}
.workout-tab span {
font-size:10px;
color:#929bad
}
.workout-tab.selected {
background:#eae6fb;
border-color:#e1daf8;
color:var(--accent)
}
.workout-tab[draggable="true"] { position:relative; cursor:grab; }
.workout-tab[draggable="true"]:active { cursor:grabbing; }
.workout-tab.workout-dragging { opacity:.35; }
.workout-drop-before::before,
.workout-drop-after::after {
content:"";
position:absolute;
left:0;
right:0;
height:3px;
border-radius:3px;
background:var(--accent);
box-shadow:0 0 0 3px #eeebfa;
pointer-events:none
}
.workout-drop-before::before { top:-6px; }
.workout-drop-after::after { bottom:-6px; }
.workout-nav>.text-button {
font-size:11px;
margin-top:15px
}
.workout-heading {
display:flex;
gap:15px;
align-items:center;
justify-content:space-between;
margin-bottom:23px
}
.workout-heading input {
font-size:20px;
font-weight:650;
background:transparent;
border:0;
padding:0;
margin:0
}
.workout-heading button {
font-size:11px
}
.block-card {
background:white;
border:1px solid var(--line);
border-radius:12px;
margin-bottom:18px;
box-shadow:0 2px 5px #20253603;
overflow:hidden
}
.block-header {
padding:14px 18px;
display:flex;
justify-content:space-between;
gap:12px;
border-bottom:1px solid #eeeff5
}
.block-header strong {
font-size:12px;
color:#626b7d
}
.block-header button {
font-size:10px;
padding:5px 8px;
border-color:transparent
}
.block-number {
font-size:11px;
color:#a1a8b7
}
.drag-handle {
cursor:grab;
font-size:23px;
color:#a5adbd;
line-height:1
}
.exercise-columns {
display:flex;
gap:24px;
padding:22px
}
.exercise-editor {
flex:1;
min-width:0
}
.exercise-heading {
display:flex;
align-items:end;
gap:15px
}
.exercise-heading input {
font-weight:600
}
.camera-toggle {
display:flex;
align-items:center;
gap:5px;
padding-bottom:9px;
cursor:pointer
}
.camera-toggle input {
margin:0
}
.camera-toggle span {
font-size:18px
}
.video-input {
margin:14px 0 20px;
font-size:10px
}
.video-input input {
font-size:12px;
background:#fafbfe
}
.block-footer {
background:#fafbfe;
border-top:1px solid #eeeff5;
padding:12px 20px;
display:flex;
justify-content:space-between;
align-items:center;
gap:15px
}
.block-footer button {
font-size:10px;
padding:6px 8px
}
.rest-input {
display:flex;
align-items:center;
gap:9px;
font-size:10px
}
.rest-input input {
width:72px;
margin:0;
font-size:12px;
padding:6px 8px
}
.add-exercise-bar {
display:flex;
align-items:end;
gap:12px;
padding:20px;
border:1px dashed #cdd2e1;
border-radius:11px;
margin:24px 0
}
.add-exercise-bar label {
font-size:10px
}
.add-exercise-bar button {
font-size:11px
}
.error {
background:#fff0f1;
border:1px solid #f0cbd0;
color:#a13d4c;
padding:14px 18px;
border-radius:9px;
font-size:13px;
margin:14px 0;
line-height:1.5
}
.error:empty {
display:none
}
.undo-message {
font-size:12px;
background:#eeebfb;
padding:5px 16px;
border-radius:8px;
margin-bottom:16px
}
.login-page {
min-height:100vh;
display:grid;
place-items:center;
background:#f0f1f7
}
.login-card {
background:#fff;
border:1px solid var(--line);
border-radius:20px;
padding:45px;
width:430px;
box-shadow:0 16px 70px #25305909
}
.login-card .brand-mark {
margin-bottom:32px
}
.login-card h1 {
font-size:28px
}
.login-card form {
margin-top:28px
}
.login-card button {
width:100%;
margin-top:12px
}
.login-card .error ul {
padding-left:15px
}
#blazor-error-ui {
background:#fff1cc;
bottom:0;
box-shadow:0 -1px 2px #0003;
display:none;
left:0;
padding:1rem;
position:fixed;
width:100%;
z-index:1000
}
.validation-message {
color:#a13d4c
}
@media(max-width:1200px) {
.sidebar {
width:200px;
padding:30px 14px
}
.main-content {
padding:30px 25px
}
.program-workspace {
grid-template-columns:145px minmax(0,1fr);
gap:18px
}
.week-tabs button {
padding:15px 18px
}
.editor-heading {
align-items:start;
flex-direction:column
}
.block-footer {
flex-wrap:wrap
}
.block-header {
flex-wrap:wrap
}
.exercise-columns {
gap:15px
}
}
@media(max-width:760px) {
.app-shell {
min-width:1040px
}
.sidebar {
width:190px
}
.main-content {
padding:25px
}
.login-card {
width:min(430px,calc(100vw - 32px));
padding:30px
}
}
.exercise-fields { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
.exercise-picker { min-width:0; }
.exercise-picker select { width:100%; }
.group-tabs { display:flex; flex-wrap:wrap; gap:8px; }
.group-tabs .selected { color:var(--accent); background:#eeebfa; border-color:var(--accent); }
.group-actions,.group-form { margin-top:16px; }
.group-form { max-width:440px; }
.exercise-editor textarea { width:100%; resize:vertical; min-height:60px; }
.workout-summary { display:flex; gap:24px; align-items:center; margin-bottom:20px; }
.workout-summary label { max-width:240px; }
.library-exercise[draggable="true"] { cursor:grab; }
.library-dragging { opacity:.35; }
.group-tabs .group-drop-target { outline:3px solid var(--accent); outline-offset:3px; background:#eeebfa; }
.library-drag-ghost { font-size:10px; overflow:hidden; overflow-wrap:anywhere; text-align:center; }
.group-panel { position:sticky; top:0; z-index:5; }
.exercise-combobox { position:relative; }
.exercise-combobox input { padding-right:44px; }
.picker-toggle { position:absolute; right:2px; top:2px; bottom:2px; border:0; background:transparent; padding:0 12px; }
.exercise-dropdown { position:fixed; inset:auto; margin:0; box-sizing:border-box; padding:6px; border:1px solid var(--line); border-radius:10px; background:white; box-shadow:0 10px 32px #20253626; overflow:auto; overscroll-behavior:contain; }
.exercise-option { display:flex; flex-direction:column; align-items:flex-start; width:100%; padding:10px 12px; border:0; background:transparent; white-space:normal; text-align:left; overflow-wrap:anywhere; }
.exercise-option strong { font-size:13px; }
.exercise-option span { font-size:11px; color:var(--muted); }
.exercise-option.active,.exercise-option[aria-selected="true"] { background:#eeebfa; color:var(--accent); }
.picker-hint,.picker-selection { font-size:11px; color:var(--muted); margin:8px; }
.exercise-import { width:min(900px,calc(100vw - 32px)); max-height:calc(100dvh - 48px); overflow:auto; border:1px solid var(--line); border-radius:16px; padding:24px; color:var(--text); background:white; box-shadow:0 20px 80px #0003; }
.exercise-import::backdrop { background:#14182780; }
.exercise-import fieldset { min-width:0; }
.import-table-scroll { overflow:auto; max-height:45vh; margin:18px 0; }
.import-table { width:100%; border-collapse:collapse; table-layout:fixed; }
.import-table th { text-align:left; background:#f5f4fa; position:sticky; top:0; z-index:1; padding:10px; }
.import-table th:last-child { width:76px; }
.import-table td { border:1px solid var(--line); padding:4px; }
.import-table input { width:100%; min-width:0; border-color:transparent; }
.exercise-import .actions { margin-top:20px; }
@@ -0,0 +1,32 @@
window.exerciseImport = {
open(dialog, receiver, maxCharacters) {
dialog.importPaste = async event => {
const input = event.target.closest('input[data-row]');
if (!input || input.matches(':disabled')) return;
event.preventDefault();
if (dialog.pasting) return;
const text = event.clipboardData.getData('text/plain');
const error = dialog.querySelector('.clipboard-error');
error.hidden = true;
if (text.length > maxCharacters) {
error.textContent = 'Слишком большой объём данных. Вставьте таблицу частями.';
error.hidden = false;
return;
}
dialog.pasting = true;
try {
await receiver.invokeMethodAsync('PasteAsync', text, Number(input.dataset.row), Number(input.dataset.column));
} catch {
error.textContent = 'Не удалось вставить данные. Проверьте соединение и повторите вставку.';
error.hidden = false;
} finally { dialog.pasting = false; }
};
dialog.addEventListener('paste', dialog.importPaste);
dialog.showModal();
dialog.querySelector('input[data-row]').focus();
},
close(dialog) {
dialog.removeEventListener('paste', dialog.importPaste);
dialog.close();
}
};

Some files were not shown because too many files have changed in this diff Show More