Compare commits
6 Commits
65a13539e0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
d6a187c01a
|
|||
|
c00e36eff4
|
|||
|
55f10fba9f
|
|||
|
fae57c6c75
|
|||
|
00688eb548
|
|||
|
fd5abef3f6
|
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -30,7 +30,7 @@
|
|||||||
"serverReadyAction": {
|
"serverReadyAction": {
|
||||||
"action": "openExternally",
|
"action": "openExternally",
|
||||||
"pattern": "\\bNow listening on:\\s+https?://\\[::\\]:(\\d+)",
|
"pattern": "\\bNow listening on:\\s+https?://\\[::\\]:(\\d+)",
|
||||||
"uriFormat": "http://localhost:%s"
|
"uriFormat": "http://localhost:%s/eHoehDhc/exercises"
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
|||||||
94
src/ASTRAIN.Client/Components/KebabMenu.razor
Normal file
94
src/ASTRAIN.Client/Components/KebabMenu.razor
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@implements IAsyncDisposable
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
<div class="kebab-menu" @ref="_menuRef">
|
||||||
|
<button class="kebab-button" type="button" @onclick="Toggle" aria-label="@AriaLabel" aria-haspopup="menu" aria-expanded="@_isOpen">⋮</button>
|
||||||
|
@if (_isOpen)
|
||||||
|
{
|
||||||
|
<div class="kebab-menu__items" role="menu">
|
||||||
|
@ChildContent(_context)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string AriaLabel { get; set; } = "Menu";
|
||||||
|
[Parameter] public RenderFragment<KebabMenuContext> ChildContent { get; set; } = default!;
|
||||||
|
|
||||||
|
private bool _isOpen;
|
||||||
|
private ElementReference _menuRef;
|
||||||
|
private DotNetObjectReference<KebabMenu>? _dotNetRef;
|
||||||
|
private IJSObjectReference? _jsRef;
|
||||||
|
private readonly KebabMenuContext _context;
|
||||||
|
|
||||||
|
public KebabMenu()
|
||||||
|
{
|
||||||
|
_context = new KebabMenuContext(Close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggles the open state of the menu.
|
||||||
|
/// </summary>
|
||||||
|
private void Toggle()
|
||||||
|
{
|
||||||
|
_isOpen = !_isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the menu if it is open.
|
||||||
|
/// </summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (!_isOpen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isOpen = false;
|
||||||
|
_ = InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called after the component has been rendered. Registers the JavaScript event handler on first render.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="firstRender">True if this is the first render; otherwise, false.</param>
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dotNetRef = DotNetObjectReference.Create(this);
|
||||||
|
_jsRef = await JS.InvokeAsync<IJSObjectReference>("kebabMenu.register", _menuRef, _dotNetRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes the JavaScript object reference and event handler.
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_jsRef is not null)
|
||||||
|
{
|
||||||
|
await _jsRef.InvokeVoidAsync("dispose");
|
||||||
|
await _jsRef.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_dotNetRef?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the KebabMenuContext class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="close">The action to close the menu.</param>
|
||||||
|
public sealed class KebabMenuContext
|
||||||
|
{
|
||||||
|
public KebabMenuContext(Action close)
|
||||||
|
{
|
||||||
|
Close = close;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action Close { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>Exercises</h1>
|
<h1>Exercises</h1>
|
||||||
<p>Create and manage your exercise list.</p>
|
<p>Create and manage your exercise list.</p>
|
||||||
<button class="primary" @onclick="ToggleCreate">@(ShowCreateExercise ? "Close" : "+")</button>
|
<button class="primary" @onclick="ToggleCreate">@(ShowCreateExercise ? "x" : "+")</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (ShowCreateExercise)
|
@if (ShowCreateExercise)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>Routines</h1>
|
<h1>Routines</h1>
|
||||||
<p>Build routines from your exercise list.</p>
|
<p>Build routines from your exercise list.</p>
|
||||||
<button class="primary" @onclick="ToggleCreate">@(ShowCreateRoutine ? "Close" : "+")</button>
|
<button class="primary" @onclick="ToggleCreate">@(ShowCreateRoutine ? "x" : "+")</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (ActiveRun is null)
|
@if (ActiveRun is null)
|
||||||
@@ -36,10 +36,11 @@
|
|||||||
<div class="list">
|
<div class="list">
|
||||||
@foreach (var exercise in ExerciseList)
|
@foreach (var exercise in ExerciseList)
|
||||||
{
|
{
|
||||||
<label class="checkbox-row">
|
var isSelected = SelectedExerciseIds.Contains(exercise.Id);
|
||||||
<input type="checkbox" checked="@SelectedExerciseIds.Contains(exercise.Id)" @onchange="() => ToggleExercise(exercise.Id)" />
|
<div class="list-item selectable @(isSelected ? "selected" : string.Empty)" @onclick="() => ToggleExercise(exercise.Id)">
|
||||||
<span>@exercise.Name</span>
|
<div class="item-title">@exercise.Name</div>
|
||||||
</label>
|
<span class="check-icon @(isSelected ? "visible" : string.Empty)" aria-hidden="true">✓</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="primary" @onclick="CreateRoutineAsync" disabled="@string.IsNullOrWhiteSpace(NewRoutineName)">Save Routine</button>
|
<button class="primary" @onclick="CreateRoutineAsync" disabled="@string.IsNullOrWhiteSpace(NewRoutineName)">Save Routine</button>
|
||||||
@@ -69,9 +70,11 @@
|
|||||||
<div class="item-subtitle">@string.Join(" · ", routine.Exercises.Select(e => e.Name))</div>
|
<div class="item-subtitle">@string.Join(" · ", routine.Exercises.Select(e => e.Name))</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="ghost" @onclick="() => StartEdit(routine)" aria-label="Edit routine">✏️</button>
|
<KebabMenu AriaLabel="Routine actions" Context="menu">
|
||||||
<button class="ghost" @onclick="() => DeleteRoutineAsync(routine.Id)" aria-label="Delete routine">🗑️</button>
|
<button class="kebab-menu__item" role="menuitem" @onclick="() => { StartEdit(routine); menu.Close(); }">Edit</button>
|
||||||
<button class="primary" @onclick="() => StartRun(routine)">Start</button>
|
<button class="kebab-menu__item danger" role="menuitem" @onclick="async () => { await DeleteRoutineAsync(routine.Id); menu.Close(); }">Delete</button>
|
||||||
|
</KebabMenu>
|
||||||
|
<button class="primary start-button" @onclick="() => StartRun(routine)" aria-label="Start routine">▶</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -89,10 +92,11 @@
|
|||||||
<div class="list">
|
<div class="list">
|
||||||
@foreach (var exercise in ExerciseList)
|
@foreach (var exercise in ExerciseList)
|
||||||
{
|
{
|
||||||
<label class="checkbox-row">
|
var isSelected = EditingExerciseIds.Contains(exercise.Id);
|
||||||
<input type="checkbox" checked="@EditingExerciseIds.Contains(exercise.Id)" @onchange="() => ToggleEditExercise(exercise.Id)" />
|
<div class="list-item selectable @(isSelected ? "selected" : string.Empty)" @onclick="() => ToggleEditExercise(exercise.Id)">
|
||||||
<span>@exercise.Name</span>
|
<div class="item-title">@exercise.Name</div>
|
||||||
</label>
|
<span class="check-icon @(isSelected ? "visible" : string.Empty)" aria-hidden="true">✓</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -109,16 +113,21 @@
|
|||||||
<div class="list">
|
<div class="list">
|
||||||
@foreach (var entry in RunEntries)
|
@foreach (var entry in RunEntries)
|
||||||
{
|
{
|
||||||
<div class="list-item @(entry.Completed ? "done" : string.Empty)">
|
<div class="list-item selectable @(entry.Completed ? "selected" : string.Empty)" @onclick="() => ToggleRunCompleted(entry.ExerciseId)">
|
||||||
<label class="checkbox-row">
|
<div class="item-title">@GetExerciseName(entry.ExerciseId)</div>
|
||||||
<input type="checkbox" checked="@entry.Completed" @onchange="() => ToggleRunCompleted(entry.ExerciseId)" />
|
<div class="item-actions" @onclick:stopPropagation="true">
|
||||||
<span>@GetExerciseName(entry.ExerciseId)</span>
|
|
||||||
</label>
|
|
||||||
<div class="input-unit">
|
<div class="input-unit">
|
||||||
<input class="input input-sm" type="number" step="0.5" @bind="entry.Weight" @bind:event="oninput" />
|
<select class="input input-sm" @bind="entry.Weight">
|
||||||
|
@foreach (var w in WeightOptions)
|
||||||
|
{
|
||||||
|
<option value="@w">@w</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
<span class="unit">kg</span>
|
<span class="unit">kg</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="check-icon @(entry.Completed ? "visible" : string.Empty)" aria-hidden="true">✓</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@@ -32,6 +32,42 @@ public partial class Routines
|
|||||||
private RoutineDto? ActiveRun { get; set; }
|
private RoutineDto? ActiveRun { get; set; }
|
||||||
private List<RoutineRunEntryDto> RunEntries { get; set; } = new();
|
private List<RoutineRunEntryDto> RunEntries { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Available weight options for the routine run select input.
|
||||||
|
/// 10–40 kg in 2.5 kg steps, then 40–100 kg in 5 kg steps.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly List<double> WeightOptions = BuildWeightOptions();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the list of available weight options for the routine run select input.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of double values representing weight options.</returns>
|
||||||
|
private static List<double> BuildWeightOptions()
|
||||||
|
{
|
||||||
|
var options = new List<double>();
|
||||||
|
for (var w = 10.0; w <= 40.0; w += 2.5)
|
||||||
|
{
|
||||||
|
options.Add(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var w = 45.0; w <= 100.0; w += 5.0)
|
||||||
|
{
|
||||||
|
options.Add(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snaps a weight value to the nearest available option.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="weight">The weight value to snap to the nearest option.</param>
|
||||||
|
/// <returns>The nearest available weight option.</returns>
|
||||||
|
private static double SnapToNearest(double weight)
|
||||||
|
{
|
||||||
|
return WeightOptions.OrderBy(w => Math.Abs(w - weight)).First();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -197,7 +233,7 @@ public partial class Routines
|
|||||||
.Select(e =>
|
.Select(e =>
|
||||||
{
|
{
|
||||||
var last = lastRun.Entries.FirstOrDefault(x => x.ExerciseId == e.ExerciseId);
|
var last = lastRun.Entries.FirstOrDefault(x => x.ExerciseId == e.ExerciseId);
|
||||||
return new RoutineRunEntryDto(e.ExerciseId, last?.Weight ?? 0, false);
|
return new RoutineRunEntryDto(e.ExerciseId, SnapToNearest(last?.Weight ?? WeightOptions[0]), false);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using ASTRAIN.Client
|
@using ASTRAIN.Client
|
||||||
|
@using ASTRAIN.Client.Components
|
||||||
@using ASTRAIN.Client.Layout
|
@using ASTRAIN.Client.Layout
|
||||||
@using ASTRAIN.Client.Services
|
@using ASTRAIN.Client.Services
|
||||||
@using ASTRAIN.Shared.Dtos
|
@using ASTRAIN.Shared.Dtos
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ p {
|
|||||||
|
|
||||||
.input-sm {
|
.input-sm {
|
||||||
max-width: 110px;
|
max-width: 110px;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
@@ -139,10 +141,47 @@ p {
|
|||||||
border-color: #1f6b38;
|
border-color: #1f6b38;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item.selectable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item.selected {
|
||||||
|
background: #10331a;
|
||||||
|
border-color: #1f6b38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
border-color: #1f6b38;
|
||||||
|
}
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.item-subtitle {
|
.item-subtitle {
|
||||||
color: #bdbdbd;
|
color: #bdbdbd;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -154,6 +193,74 @@ p {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.start-button {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-button {
|
||||||
|
list-style: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-button::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-menu__items {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
min-width: 140px;
|
||||||
|
background: #151515;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.35rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-menu__item {
|
||||||
|
background: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-menu__item:hover {
|
||||||
|
background: #222222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab-menu__item.danger {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-row {
|
.checkbox-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -216,6 +323,8 @@ p {
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem 1.25rem 6rem;
|
padding: 1.5rem 1.25rem 6rem;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-header {
|
.brand-header {
|
||||||
@@ -305,7 +414,7 @@ p {
|
|||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 2rem 2.5rem 7rem;
|
padding: 2rem 2.5rem 7rem;
|
||||||
max-width: 960px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<a href="." class="reload">Reload</a>
|
<a href="." class="reload">Reload</a>
|
||||||
<span class="dismiss">🗙</span>
|
<span class="dismiss">🗙</span>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="js/kebabMenu.js"></script>
|
||||||
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
28
src/ASTRAIN.Client/wwwroot/js/kebabMenu.js
Normal file
28
src/ASTRAIN.Client/wwwroot/js/kebabMenu.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
window.kebabMenu = {
|
||||||
|
/**
|
||||||
|
* Registers the kebab menu event handler for closing on outside clicks.
|
||||||
|
* @param {HTMLElement} element - The menu element.
|
||||||
|
* @param {object} dotNetRef - The .NET object reference.
|
||||||
|
* @returns {object} An object with a dispose method.
|
||||||
|
*/
|
||||||
|
register: function (element, dotNetRef) {
|
||||||
|
if (!element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (event) => {
|
||||||
|
if (!element.contains(event.target)) {
|
||||||
|
dotNetRef.invokeMethodAsync('Close');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handler, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Disposes the event listener.
|
||||||
|
dispose: function () {
|
||||||
|
document.removeEventListener('click', handler, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user