Compare commits

..

22 Commits

Author SHA1 Message Date
d6a187c01a Enhance routine run UI with selectable items, check icons, and weight select dropdown 2026-02-07 18:30:03 +01:00
c00e36eff4 Update client launch URI to exercises page 2026-02-07 18:11:21 +01:00
55f10fba9f Enhance exercise selection UI in Routines page 2026-02-07 18:11:19 +01:00
fae57c6c75 Update button text for toggling exercise and routine creation to use 'x' instead of 'Close' 2026-02-04 21:51:15 +01:00
00688eb548 Refactor Routines page to use KebabMenu for edit/delete actions and update button styles 2026-02-04 21:47:20 +01:00
fd5abef3f6 Add KebabMenu component with JavaScript handler and styles for dropdown actions 2026-02-04 21:47:08 +01:00
65a13539e0 Add VS Code workspace settings
- Configure auto-approve for git commit in chat tools
- Set default solution file
2026-02-04 21:29:04 +01:00
fd8395cc48 Update configuration for deployment
- Change API base address to external IP for development
- Update launch settings to bind to any host interface
- Remove commented CORS policy code
2026-02-04 21:29:00 +01:00
8f2284e1fc Remove unused Blazor pages
- Delete Counter.razor and Weather.razor as they are not needed for the application
2026-02-04 21:28:54 +01:00
5db6fee866 Refactor Blazor components to use code-behind files
- Move @code blocks from Exercises.razor, Home.razor, and Routines.razor to separate .cs files
- Add XML documentation comments to all methods in the code-behind files
2026-02-04 21:28:44 +01:00
01581b7a91 Add Docker build/push tooling and Traefik compose 2026-02-01 10:29:14 +01:00
56aacb0134 Add sample data toggle to user provisioning 2026-02-01 10:29:00 +01:00
990e67e88c feat: enhance app layout with new shell and navigation styles 2026-01-31 19:09:56 +01:00
81caddbea3 feat: enable serving unknown file types in static files middleware 2026-01-31 19:09:50 +01:00
2e69f0d5ef feat: add Docker image build script and update tasks configuration 2026-01-31 19:09:35 +01:00
e690a649e8 feat: add sample data population for new users and improve exercise list layout 2026-01-31 01:13:23 +01:00
e81bf53def feat: integrate Swagger for API documentation 2026-01-31 00:22:30 +01:00
8875060917 feat: add confirmation dialogs for deleting exercises and routines 2026-01-31 00:22:22 +01:00
8300331276 feat: add delete functionality for exercises and routines 2026-01-31 00:18:30 +01:00
81d6b70673 fix: update meta tags for mobile web app capabilities 2026-01-31 00:08:32 +01:00
70e115f2a0 chore: update launch configurations and add debug build tasks 2026-01-31 00:03:00 +01:00
e35f45dc4d Refactor API endpoints into modules 2026-01-30 23:59:53 +01:00
34 changed files with 1724 additions and 726 deletions

25
.vscode/launch.json vendored
View File

@@ -5,16 +5,11 @@
"name": "ASTRAIN.Api",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"preLaunchTask": "Debug Build API",
"program": "${workspaceFolder}/src/ASTRAIN.Api/bin/Debug/net10.0/ASTRAIN.Api.dll",
"args": [],
"cwd": "${workspaceFolder}/src/ASTRAIN.Api",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "Now listening on: (https?://\\S+)",
"uriFormat": "%s"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -23,15 +18,19 @@
"name": "ASTRAIN.Client",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/ASTRAIN.Client/bin/Debug/net10.0/ASTRAIN.Client.dll",
"args": [],
"cwd": "${workspaceFolder}/src/ASTRAIN.Client",
"preLaunchTask": "Debug Build Client",
"program": "dotnet",
"args": [
"run",
"--project",
"${workspaceFolder}/src/ASTRAIN.Client"
],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "Now listening on: (https?://\\S+)",
"uriFormat": "%s"
"pattern": "\\bNow listening on:\\s+https?://\\[::\\]:(\\d+)",
"uriFormat": "http://localhost:%s/eHoehDhc/exercises"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -47,4 +46,4 @@
]
}
]
}
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"chat.tools.terminal.autoApprove": {
"git commit": true
},
"dotnet.defaultSolution": "ASTRAIN.slnx"
}

36
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,36 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Debug Build API",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/src/ASTRAIN.Api"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "Debug Build Client",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/src/ASTRAIN.Client"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "Build & Push Docker",
"type": "shell",
"command": "${workspaceFolder}\\.venv\\Scripts\\python.exe",
"args": [
"${workspaceFolder}/docker/build_and_push_image.py"
],
"problemMatcher": []
}
]
}

View File

@@ -0,0 +1,15 @@
services:
astrain:
image: git.beging.de/troogs/astrain:latest
restart: unless-stopped
container_name: astrain
networks:
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.fs-onboarding-si.rule=Host(`astrain.melvin.beging.de`)
- traefik.http.services.fs-onboarding-si.loadbalancer.server.port=8080
networks:
proxy:
external: true

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def main() -> int:
script_dir = Path(__file__).resolve().parent
scripts = ["build_image.py", "push_image.py"]
for script in scripts:
script_path = script_dir / script
print(f"Executing {script}...")
try:
subprocess.run([sys.executable, str(script_path)], check=True)
print(f"{script} executed successfully.\n")
except subprocess.CalledProcessError as error:
print(f"Error: {script} failed with exit code {error.returncode}.")
return error.returncode
return 0
if __name__ == "__main__":
raise SystemExit(main())

44
docker/build_image.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
import datetime as dt
import subprocess
from pathlib import Path
def get_timestamp_tag(today: dt.date) -> str:
year_suffix = today.year % 100
day_of_year = today.timetuple().tm_yday
return f"{year_suffix}.{day_of_year}"
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
dockerfile_path = repo_root / "docker" / "Dockerfile"
today = dt.date.today()
timestamp_tag = get_timestamp_tag(today)
image_name = "troogs/astrain"
tags = ["latest", timestamp_tag]
build_cmd = [
"docker",
"build",
"-f",
str(dockerfile_path),
"-t",
f"{image_name}:{tags[0]}",
"-t",
f"{image_name}:{tags[1]}",
str(repo_root),
]
print(f"Building Docker image with tags: {', '.join(tags)}")
print(" ".join(build_cmd))
result = subprocess.run(build_cmd, cwd=str(repo_root))
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())

57
docker/push_image.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import datetime as dt
import subprocess
from pathlib import Path
def get_timestamp_tag(today: dt.date) -> str:
year_suffix = today.year % 100
day_of_year = today.timetuple().tm_yday
return f"{year_suffix}.{day_of_year}"
def run_command(command: list[str], repo_root: Path) -> int:
print(" ".join(command))
result = subprocess.run(command, cwd=str(repo_root))
return result.returncode
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
today = dt.date.today()
timestamp_tag = get_timestamp_tag(today)
local_image = "troogs/astrain"
registry_image = "git.beging.de/troogs/astrain"
tags = ["latest", timestamp_tag]
for tag in tags:
tag_cmd = [
"docker",
"tag",
f"{local_image}:{tag}",
f"{registry_image}:{tag}",
]
print(f"Tagging {local_image}:{tag} as {registry_image}:{tag}")
exit_code = run_command(tag_cmd, repo_root)
if exit_code != 0:
return exit_code
for tag in tags:
push_cmd = [
"docker",
"push",
f"{registry_image}:{tag}",
]
print(f"Pushing {registry_image}:{tag}")
exit_code = run_command(push_cmd, repo_root)
if exit_code != 0:
return exit_code
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,106 @@
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Models;
using ASTRAIN.Shared.Requests;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides exercise-related endpoints.
/// </summary>
internal static class ExerciseEndpoints
{
/// <summary>
/// Registers exercise routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapExerciseEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var items = await db.Exercises
.Where(e => e.UserId == user.Id)
.OrderBy(e => e.Name)
.Select(e => new ExerciseDto(e.Id, e.Name))
.ToListAsync();
return Results.Ok(items);
})
.WithSummary("List exercises")
.WithDescription("Returns the exercises for the specified user.");
group.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var exercise = new Exercise
{
Name = request.Name.Trim(),
UserId = user.Id
};
db.Exercises.Add(exercise);
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
})
.WithSummary("Create exercise")
.WithDescription("Creates a new exercise for the specified user.");
group.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
if (exercise is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
exercise.Name = request.Name.Trim();
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
})
.WithSummary("Update exercise")
.WithDescription("Updates the name of an exercise for the specified user.");
group.MapDelete("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
if (exercise is null)
{
return Results.NotFound();
}
db.Exercises.Remove(exercise);
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithSummary("Delete exercise")
.WithDescription("Deletes an exercise for the specified user.");
return group;
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides health check endpoints.
/// </summary>
internal static class HealthEndpoints
{
/// <summary>
/// Registers health check routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapHealthEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/health", () => Results.Ok(new { status = "ok" }))
.WithSummary("Health check")
.WithDescription("Returns a simple payload used for liveness checks.");
return group;
}
}

View File

@@ -0,0 +1,188 @@
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Models;
using ASTRAIN.Shared.Requests;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides routine-related endpoints.
/// </summary>
internal static class RoutineEndpoints
{
/// <summary>
/// Registers routine routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapRoutineEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routines = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.Where(r => r.UserId == user.Id)
.OrderBy(r => r.Name)
.ToListAsync();
var payload = routines.Select(r => new RoutineDto(
r.Id,
r.Name,
r.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList()
));
return Results.Ok(payload);
})
.WithSummary("List routines")
.WithDescription("Returns all routines for the specified user.");
group.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routine = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var payload = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(payload);
})
.WithSummary("Get routine")
.WithDescription("Returns a specific routine and its exercises.");
group.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var routine = new Routine
{
Name = request.Name.Trim(),
UserId = user.Id
};
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
routine.Exercises = request.ExerciseIds
.Select((exerciseId, index) => new RoutineExercise
{
ExerciseId = exerciseId,
Order = index
})
.ToList();
db.Routines.Add(routine);
await db.SaveChangesAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.Select((re, index) => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, index))
.ToList());
return Results.Ok(dto);
})
.WithSummary("Create routine")
.WithDescription("Creates a routine and associates exercises with it.");
group.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routine = await db.Routines
.Include(r => r.Exercises)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
routine.Name = request.Name.Trim();
routine.Exercises.Clear();
foreach (var exerciseId in request.ExerciseIds)
{
routine.Exercises.Add(new RoutineExercise
{
ExerciseId = exerciseId,
Order = routine.Exercises.Count
});
}
await db.SaveChangesAsync();
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(dto);
})
.WithSummary("Update routine")
.WithDescription("Updates routine metadata and exercise ordering.");
group.MapDelete("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
db.Routines.Remove(routine);
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithSummary("Delete routine")
.WithDescription("Deletes a routine and its associated data for the specified user.");
return group;
}
}

View File

@@ -0,0 +1,85 @@
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Models;
using ASTRAIN.Shared.Requests;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides routine run-related endpoints.
/// </summary>
internal static class RoutineRunEndpoints
{
/// <summary>
/// Registers routine run routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapRoutineRunEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var lastRun = await db.RoutineRuns
.Include(rr => rr.Entries)
.Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId)
.OrderByDescending(rr => rr.PerformedAt)
.FirstOrDefaultAsync();
if (lastRun is null)
{
return Results.Ok(new RoutineRunSummaryDto(DateTime.MinValue, new List<RoutineRunEntryDto>()));
}
var summary = new RoutineRunSummaryDto(
lastRun.PerformedAt,
lastRun.Entries
.Select(e => new RoutineRunEntryDto(e.ExerciseId, e.Weight, e.Completed))
.ToList());
return Results.Ok(summary);
})
.WithSummary("Get last routine run")
.WithDescription("Returns the most recent run summary for a routine.");
group.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var run = new RoutineRun
{
RoutineId = routine.Id,
UserId = user.Id,
PerformedAt = DateTime.UtcNow,
Entries = request.Entries.Select(entry => new RoutineRunEntry
{
ExerciseId = entry.ExerciseId,
Weight = entry.Weight,
Completed = entry.Completed
}).ToList()
};
db.RoutineRuns.Add(run);
await db.SaveChangesAsync();
return Results.Ok(new { run.Id, run.PerformedAt });
})
.WithSummary("Create routine run")
.WithDescription("Records a routine run with exercise entries.");
return group;
}
}

View File

@@ -0,0 +1,43 @@
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Responses;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides user-related endpoints.
/// </summary>
internal static class UserEndpoints
{
/// <summary>
/// Registers user routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapUserEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/ensure", async (string? userId, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
return Results.Ok(new EnsureUserResponse(user.Id));
})
.WithSummary("Ensure user")
.WithDescription("Ensures a user exists and returns the user id.");
group.MapGet("/users/{userId}", async (string userId, AppDbContext db, IConfiguration config) =>
{
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
return Results.Ok(new EnsureUserResponse(user.Id));
})
.WithSummary("Get user")
.WithDescription("Returns the user id if it exists, or creates a new user.");
return group;
}
}

View File

@@ -1,20 +1,18 @@
using System.Text.RegularExpressions;
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Models;
using ASTRAIN.Shared.Requests;
using ASTRAIN.Shared.Responses;
using ASTRAIN.Api.Endpoints;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSwaggerGen();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("http://localhost:5014", "https://localhost:7252")
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
@@ -31,12 +29,23 @@ var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "ASTRAIN API");
options.RoutePrefix = "docs";
});
}
app.UseHttpsRedirection();
// app.UseHttpsRedirection();
app.UseCors();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
ServeUnknownFileTypes = true
});
using (var scope = app.Services.CreateScope())
{
@@ -46,289 +55,12 @@ using (var scope = app.Services.CreateScope())
var api = app.MapGroup("/api");
api.MapGet("/health", () => Results.Ok(new { status = "ok" }));
api.MapGet("/users/ensure", async (string? userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
return Results.Ok(new EnsureUserResponse(user.Id));
});
api.MapGet("/users/{userId}", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
return Results.Ok(new EnsureUserResponse(user.Id));
});
api.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var items = await db.Exercises
.Where(e => e.UserId == user.Id)
.OrderBy(e => e.Name)
.Select(e => new ExerciseDto(e.Id, e.Name))
.ToListAsync();
return Results.Ok(items);
});
api.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var exercise = new Exercise
{
Name = request.Name.Trim(),
UserId = user.Id
};
db.Exercises.Add(exercise);
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
});
api.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
if (exercise is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
exercise.Name = request.Name.Trim();
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
});
api.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routines = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.Where(r => r.UserId == user.Id)
.OrderBy(r => r.Name)
.ToListAsync();
var payload = routines.Select(r => new RoutineDto(
r.Id,
r.Name,
r.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList()
));
return Results.Ok(payload);
});
api.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var payload = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(payload);
});
api.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var routine = new Routine
{
Name = request.Name.Trim(),
UserId = user.Id
};
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
routine.Exercises = request.ExerciseIds
.Select((exerciseId, index) => new RoutineExercise
{
ExerciseId = exerciseId,
Order = index
})
.ToList();
db.Routines.Add(routine);
await db.SaveChangesAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.Select((re, index) => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, index))
.ToList());
return Results.Ok(dto);
});
api.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines
.Include(r => r.Exercises)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
routine.Name = request.Name.Trim();
routine.Exercises.Clear();
foreach (var exerciseId in request.ExerciseIds)
{
routine.Exercises.Add(new RoutineExercise
{
ExerciseId = exerciseId,
Order = routine.Exercises.Count
});
}
await db.SaveChangesAsync();
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(dto);
});
api.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var lastRun = await db.RoutineRuns
.Include(rr => rr.Entries)
.Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId)
.OrderByDescending(rr => rr.PerformedAt)
.FirstOrDefaultAsync();
if (lastRun is null)
{
return Results.Ok(new RoutineRunSummaryDto(DateTime.MinValue, new List<RoutineRunEntryDto>()));
}
var summary = new RoutineRunSummaryDto(
lastRun.PerformedAt,
lastRun.Entries
.Select(e => new RoutineRunEntryDto(e.ExerciseId, e.Weight, e.Completed))
.ToList());
return Results.Ok(summary);
});
api.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var run = new RoutineRun
{
RoutineId = routine.Id,
UserId = user.Id,
PerformedAt = DateTime.UtcNow,
Entries = request.Entries.Select(entry => new RoutineRunEntry
{
ExerciseId = entry.ExerciseId,
Weight = entry.Weight,
Completed = entry.Completed
}).ToList()
};
db.RoutineRuns.Add(run);
await db.SaveChangesAsync();
return Results.Ok(new { run.Id, run.PerformedAt });
});
api.MapHealthEndpoints();
api.MapUserEndpoints();
api.MapExerciseEndpoints();
api.MapRoutineEndpoints();
api.MapRoutineRunEndpoints();
app.MapFallbackToFile("index.html");
app.Run();
static async Task<User> EnsureUserAsync(AppDbContext db, string? userId)
{
if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId))
{
var existing = await db.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (existing is not null)
{
return existing;
}
var created = new User { Id = userId };
db.Users.Add(created);
await db.SaveChangesAsync();
return created;
}
while (true)
{
var newId = UserKeyGenerator.Generate(8);
var exists = await db.Users.AnyAsync(u => u.Id == newId);
if (exists)
{
continue;
}
var user = new User { Id = newId };
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
}
static bool IsValidUserId(string userId)
{
return Regex.IsMatch(userId, "^[A-Za-z0-9]{8}$");
}

View File

@@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5055",
"applicationUrl": "http://+:5055",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7049;http://localhost:5055",
"applicationUrl": "https://+:7049;http://+:5055",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -0,0 +1,121 @@
using System.Text.RegularExpressions;
using ASTRAIN.Api.Data;
using ASTRAIN.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace ASTRAIN.Api.Services;
/// <summary>
/// Provides helper methods for ensuring and validating application users.
/// </summary>
internal static class UserProvisioning
{
/// <summary>
/// Ensures a user exists in the database and returns the user record.
/// </summary>
/// <param name="db">The application database context.</param>
/// <param name="userId">Optional user id to validate or create.</param>
/// <param name="populateSampleData">Whether to populate sample data for new users.</param>
/// <returns>The existing or newly created user.</returns>
public static async Task<User> EnsureUserAsync(AppDbContext db, string? userId, bool populateSampleData)
{
if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId))
{
var existing = await db.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (existing is not null)
{
return existing;
}
var created = new User { Id = userId };
db.Users.Add(created);
await db.SaveChangesAsync();
if (populateSampleData)
{
await PopulateSampleDataAsync(db, created);
}
return created;
}
while (true)
{
var newId = UserKeyGenerator.Generate(8);
var exists = await db.Users.AnyAsync(u => u.Id == newId);
if (exists)
{
continue;
}
var user = new User { Id = newId };
db.Users.Add(user);
await db.SaveChangesAsync();
if (populateSampleData)
{
await PopulateSampleDataAsync(db, user);
}
return user;
}
}
/// <summary>
/// Populates a new user with sample exercises and routines for debugging.
/// </summary>
/// <param name="db">The application database context.</param>
/// <param name="user">The newly created user.</param>
private static async Task PopulateSampleDataAsync(AppDbContext db, User user)
{
// Sample exercises
var exercises = new[]
{
new Exercise { Name = "Push-ups", UserId = user.Id },
new Exercise { Name = "Squats", UserId = user.Id },
new Exercise { Name = "Pull-ups", UserId = user.Id },
new Exercise { Name = "Bench Press", UserId = user.Id },
new Exercise { Name = "Deadlift", UserId = user.Id }
};
db.Exercises.AddRange(exercises);
await db.SaveChangesAsync();
// Sample routines
var routine1 = new Routine { Name = "Upper Body", UserId = user.Id };
var routine2 = new Routine { Name = "Lower Body", UserId = user.Id };
db.Routines.AddRange(routine1, routine2);
await db.SaveChangesAsync();
// Associate exercises with routines
var pushUps = exercises.First(e => e.Name == "Push-ups");
var pullUps = exercises.First(e => e.Name == "Pull-ups");
var benchPress = exercises.First(e => e.Name == "Bench Press");
var squats = exercises.First(e => e.Name == "Squats");
var deadlift = exercises.First(e => e.Name == "Deadlift");
var routineExercises1 = new[]
{
new RoutineExercise { RoutineId = routine1.Id, ExerciseId = pushUps.Id, Order = 0 },
new RoutineExercise { RoutineId = routine1.Id, ExerciseId = pullUps.Id, Order = 1 },
new RoutineExercise { RoutineId = routine1.Id, ExerciseId = benchPress.Id, Order = 2 }
};
var routineExercises2 = new[]
{
new RoutineExercise { RoutineId = routine2.Id, ExerciseId = squats.Id, Order = 0 },
new RoutineExercise { RoutineId = routine2.Id, ExerciseId = deadlift.Id, Order = 1 }
};
db.RoutineExercises.AddRange(routineExercises1);
db.RoutineExercises.AddRange(routineExercises2);
await db.SaveChangesAsync();
}
/// <summary>
/// Determines whether a user id matches the expected format.
/// </summary>
/// <param name="userId">The user id to validate.</param>
/// <returns><c>true</c> if the id is valid; otherwise <c>false</c>.</returns>
public static bool IsValidUserId(string userId)
{
return Regex.IsMatch(userId, "^[A-Za-z0-9]{8}$");
}
}

View File

@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"SampleData": {
"Enabled": false
}
}

View File

@@ -8,5 +8,8 @@
"ConnectionStrings": {
"Default": "Data Source=Data/astrain.db"
},
"SampleData": {
"Enabled": false
},
"AllowedHosts": "*"
}

View 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; }
}
}

View File

@@ -1,18 +0,0 @@
@page "/counter"
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}

View File

@@ -4,6 +4,7 @@
@inject ApiClient Api
@inject NavigationManager Navigation
@inject UserContext UserContext
@inject IJSRuntime JS
<PageTitle>Exercises</PageTitle>
@@ -11,7 +12,7 @@
<header class="page-header">
<h1>Exercises</h1>
<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>
@if (ShowCreateExercise)
@@ -40,17 +41,22 @@
<div class="list">
@foreach (var exercise in ExerciseList)
{
<div class="list-item">
<div class="list-item" style="flex-direction: column; align-items: flex-start;">
@if (EditingId == exercise.Id)
{
<input class="input" @bind="EditingName" @bind:event="oninput" />
<button class="primary" @onclick="() => SaveEditAsync(exercise.Id)">Save</button>
<button class="ghost" @onclick="CancelEdit">Cancel</button>
<div class="item-actions">
<button class="primary" @onclick="() => SaveEditAsync(exercise.Id)">Save</button>
<button class="ghost" @onclick="CancelEdit">Cancel</button>
</div>
}
else
{
<div class="item-title">@exercise.Name</div>
<button class="ghost" @onclick="() => StartEdit(exercise)">Edit</button>
<div class="item-title" style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden; width: 100%;">@exercise.Name</div>
<div class="item-actions">
<button class="ghost" @onclick="() => StartEdit(exercise)" aria-label="Edit exercise">✏️</button>
<button class="ghost" @onclick="() => DeleteExerciseAsync(exercise.Id)" aria-label="Delete exercise">🗑️</button>
</div>
}
</div>
}
@@ -59,93 +65,3 @@
</section>
</div>
@code {
[Parameter]
public string? UserId { get; set; }
private List<ExerciseDto> ExerciseList { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool ShowCreateExercise { get; set; }
private string NewExerciseName { get; set; } = string.Empty;
private int? EditingId { get; set; }
private string EditingName { get; set; } = string.Empty;
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
if (UserId != ensured)
{
Navigation.NavigateTo($"/{ensured}/exercises", true);
return;
}
await LoadExercisesAsync();
}
private async Task LoadExercisesAsync()
{
IsLoading = true;
ExerciseList = await Api.GetExercisesAsync(UserContext.UserId);
IsLoading = false;
}
private async Task CreateExerciseAsync()
{
if (string.IsNullOrWhiteSpace(NewExerciseName))
{
return;
}
var result = await Api.CreateExerciseAsync(UserContext.UserId, new ExerciseUpsertRequest(NewExerciseName));
if (result is not null)
{
ExerciseList.Add(result);
ExerciseList = ExerciseList.OrderBy(e => e.Name).ToList();
NewExerciseName = string.Empty;
}
}
private void ToggleCreate()
{
ShowCreateExercise = !ShowCreateExercise;
}
private void StartEdit(ExerciseDto exercise)
{
EditingId = exercise.Id;
EditingName = exercise.Name;
}
private void CancelEdit()
{
EditingId = null;
EditingName = string.Empty;
}
private async Task SaveEditAsync(int exerciseId)
{
if (string.IsNullOrWhiteSpace(EditingName))
{
return;
}
var result = await Api.UpdateExerciseAsync(UserContext.UserId, exerciseId, new ExerciseUpsertRequest(EditingName));
if (result is not null)
{
var index = ExerciseList.FindIndex(e => e.Id == exerciseId);
if (index >= 0)
{
ExerciseList[index] = result;
ExerciseList = ExerciseList.OrderBy(e => e.Name).ToList();
}
}
CancelEdit();
}
}

View File

@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Requests;
namespace ASTRAIN.Client.Pages;
public partial class Exercises
{
/// <summary>
/// Optional user id from route.
/// </summary>
[Parameter]
public string? UserId { get; set; }
/// <summary>
/// The list of exercises for the current user.
/// </summary>
private List<ExerciseDto> ExerciseList { get; set; } = new();
/// <summary>
/// Whether the page is currently loading data.
/// </summary>
private bool IsLoading { get; set; } = true;
/// <summary>
/// Whether the create exercise UI is visible.
/// </summary>
private bool ShowCreateExercise { get; set; }
/// <summary>
/// Name for a new exercise being created.
/// </summary>
private string NewExerciseName { get; set; } = string.Empty;
/// <summary>
/// The currently edited exercise id, if any.
/// </summary>
private int? EditingId { get; set; }
/// <summary>
/// The current edited exercise name.
/// </summary>
private string EditingName { get; set; } = string.Empty;
/// <inheritdoc/>
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
if (UserId != ensured)
{
Navigation.NavigateTo($"/{ensured}/exercises", true);
return;
}
await LoadExercisesAsync();
}
/// <summary>
/// Loads exercises from the API.
/// </summary>
private async Task LoadExercisesAsync()
{
IsLoading = true;
ExerciseList = await Api.GetExercisesAsync(UserContext.UserId);
IsLoading = false;
}
/// <summary>
/// Creates a new exercise with the provided name.
/// </summary>
private async Task CreateExerciseAsync()
{
if (string.IsNullOrWhiteSpace(NewExerciseName))
{
return;
}
var result = await Api.CreateExerciseAsync(UserContext.UserId, new ExerciseUpsertRequest(NewExerciseName));
if (result is not null)
{
ExerciseList.Add(result);
ExerciseList = ExerciseList.OrderBy(e => e.Name).ToList();
NewExerciseName = string.Empty;
}
}
/// <summary>
/// Toggles the create form visibility.
/// </summary>
private void ToggleCreate()
{
ShowCreateExercise = !ShowCreateExercise;
}
/// <summary>
/// Begin editing the supplied exercise.
/// </summary>
private void StartEdit(ExerciseDto exercise)
{
EditingId = exercise.Id;
EditingName = exercise.Name;
}
/// <summary>
/// Cancel the current edit.
/// </summary>
private void CancelEdit()
{
EditingId = null;
EditingName = string.Empty;
}
/// <summary>
/// Save the edited exercise name.
/// </summary>
private async Task SaveEditAsync(int exerciseId)
{
if (string.IsNullOrWhiteSpace(EditingName))
{
return;
}
var result = await Api.UpdateExerciseAsync(UserContext.UserId, exerciseId, new ExerciseUpsertRequest(EditingName));
if (result is not null)
{
var index = ExerciseList.FindIndex(e => e.Id == exerciseId);
if (index >= 0)
{
ExerciseList[index] = result;
ExerciseList = ExerciseList.OrderBy(e => e.Name).ToList();
}
}
CancelEdit();
}
/// <summary>
/// Deletes the exercise with the given id after confirmation.
/// </summary>
private async Task DeleteExerciseAsync(int exerciseId)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to delete this exercise?");
if (!confirmed) return;
await Api.DeleteExerciseAsync(UserContext.UserId, exerciseId);
ExerciseList.RemoveAll(e => e.Id == exerciseId);
}
}

View File

@@ -14,23 +14,3 @@
</div>
</div>
@code {
[Parameter]
public string? UserId { get; set; }
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
var target = $"/{ensured}/routines";
if (!Navigation.Uri.EndsWith(target, StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo(target, true);
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
namespace ASTRAIN.Client.Pages;
public partial class Home
{
/// <summary>
/// Optional user id from route.
/// </summary>
[Parameter]
public string? UserId { get; set; }
/// <inheritdoc/>
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
var target = $"/{ensured}/routines";
if (!Navigation.Uri.EndsWith(target, StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo(target, true);
}
}
}

View File

@@ -4,6 +4,7 @@
@inject ApiClient Api
@inject NavigationManager Navigation
@inject UserContext UserContext
@inject IJSRuntime JS
<PageTitle>Routines</PageTitle>
@@ -11,7 +12,7 @@
<header class="page-header">
<h1>Routines</h1>
<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>
@if (ActiveRun is null)
@@ -25,53 +26,63 @@
</section>
}
@if (ShowCreateRoutine)
@if (EditingRoutine is null)
{
<section class="card">
<h2>Create Routine</h2>
<input class="input" placeholder="Routine name" @bind="NewRoutineName" @bind:event="oninput" />
<div class="list">
@foreach (var exercise in ExerciseList)
{
<label class="checkbox-row">
<input type="checkbox" checked="@SelectedExerciseIds.Contains(exercise.Id)" @onchange="() => ToggleExercise(exercise.Id)" />
<span>@exercise.Name</span>
</label>
}
</div>
<button class="primary" @onclick="CreateRoutineAsync" disabled="@string.IsNullOrWhiteSpace(NewRoutineName)">Save Routine</button>
</section>
}
@if (ShowCreateRoutine)
{
<section class="card">
<h2>Create Routine</h2>
<input class="input" placeholder="Routine name" @bind="NewRoutineName" @bind:event="oninput" />
<div class="list">
@foreach (var exercise in ExerciseList)
{
var isSelected = SelectedExerciseIds.Contains(exercise.Id);
<div class="list-item selectable @(isSelected ? "selected" : string.Empty)" @onclick="() => ToggleExercise(exercise.Id)">
<div class="item-title">@exercise.Name</div>
<span class="check-icon @(isSelected ? "visible" : string.Empty)" aria-hidden="true">✓</span>
</div>
}
</div>
<button class="primary" @onclick="CreateRoutineAsync" disabled="@string.IsNullOrWhiteSpace(NewRoutineName)">Save Routine</button>
</section>
}
<section class="card">
<h2>Your Routines</h2>
@if (IsLoading)
@if (ExerciseList.Count > 0 || RoutineList.Count > 0)
{
<p>Loading...</p>
}
else if (RoutineList.Count == 0)
{
<p class="muted">No routines yet. Create one above.</p>
}
else
{
<div class="list">
@foreach (var routine in RoutineList)
<section class="card">
<h2>Your Routines</h2>
@if (IsLoading)
{
<div class="list-item">
<div>
<div class="item-title">@routine.Name</div>
<div class="item-subtitle">@string.Join(" · ", routine.Exercises.Select(e => e.Name))</div>
</div>
<div class="actions">
<button class="ghost" @onclick="() => StartEdit(routine)">Edit</button>
<button class="primary" @onclick="() => StartRun(routine)">Start</button>
</div>
<p>Loading...</p>
}
else if (RoutineList.Count == 0)
{
<p class="muted">No routines yet. Create one above.</p>
}
else
{
<div class="list">
@foreach (var routine in RoutineList)
{
<div class="list-item">
<div>
<div class="item-title">@routine.Name</div>
<div class="item-subtitle">@string.Join(" · ", routine.Exercises.Select(e => e.Name))</div>
</div>
<div class="actions">
<KebabMenu AriaLabel="Routine actions" Context="menu">
<button class="kebab-menu__item" role="menuitem" @onclick="() => { StartEdit(routine); menu.Close(); }">Edit</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>
</section>
}
</section>
}
@if (EditingRoutine is not null)
{
@@ -81,10 +92,11 @@
<div class="list">
@foreach (var exercise in ExerciseList)
{
<label class="checkbox-row">
<input type="checkbox" checked="@EditingExerciseIds.Contains(exercise.Id)" @onchange="() => ToggleEditExercise(exercise.Id)" />
<span>@exercise.Name</span>
</label>
var isSelected = EditingExerciseIds.Contains(exercise.Id);
<div class="list-item selectable @(isSelected ? "selected" : string.Empty)" @onclick="() => ToggleEditExercise(exercise.Id)">
<div class="item-title">@exercise.Name</div>
<span class="check-icon @(isSelected ? "visible" : string.Empty)" aria-hidden="true">✓</span>
</div>
}
</div>
<div class="actions">
@@ -101,15 +113,20 @@
<div class="list">
@foreach (var entry in RunEntries)
{
<div class="list-item @(entry.Completed ? "done" : string.Empty)">
<label class="checkbox-row">
<input type="checkbox" checked="@entry.Completed" @onchange="() => ToggleRunCompleted(entry.ExerciseId)" />
<span>@GetExerciseName(entry.ExerciseId)</span>
</label>
<div class="input-unit">
<input class="input input-sm" type="number" step="0.5" @bind="entry.Weight" @bind:event="oninput" />
<span class="unit">kg</span>
<div class="list-item selectable @(entry.Completed ? "selected" : string.Empty)" @onclick="() => ToggleRunCompleted(entry.ExerciseId)">
<div class="item-title">@GetExerciseName(entry.ExerciseId)</div>
<div class="item-actions" @onclick:stopPropagation="true">
<div class="input-unit">
<select class="input input-sm" @bind="entry.Weight">
@foreach (var w in WeightOptions)
{
<option value="@w">@w</option>
}
</select>
<span class="unit">kg</span>
</div>
</div>
<span class="check-icon @(entry.Completed ? "visible" : string.Empty)" aria-hidden="true">✓</span>
</div>
}
</div>
@@ -121,176 +138,3 @@
}
</div>
@code {
[Parameter]
public string? UserId { get; set; }
private List<ExerciseDto> ExerciseList { get; set; } = new();
private List<RoutineDto> RoutineList { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool ShowCreateRoutine { get; set; }
private string NewRoutineName { get; set; } = string.Empty;
private HashSet<int> SelectedExerciseIds { get; set; } = new();
private RoutineDto? EditingRoutine { get; set; }
private string EditingName { get; set; } = string.Empty;
private HashSet<int> EditingExerciseIds { get; set; } = new();
private RoutineDto? ActiveRun { get; set; }
private List<RoutineRunEntryDto> RunEntries { get; set; } = new();
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
if (UserId != ensured)
{
Navigation.NavigateTo($"/{ensured}/routines", true);
return;
}
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
IsLoading = true;
ExerciseList = await Api.GetExercisesAsync(UserContext.UserId);
RoutineList = await Api.GetRoutinesAsync(UserContext.UserId);
IsLoading = false;
}
private void ToggleExercise(int exerciseId)
{
if (!SelectedExerciseIds.Add(exerciseId))
{
SelectedExerciseIds.Remove(exerciseId);
}
}
private async Task CreateRoutineAsync()
{
if (string.IsNullOrWhiteSpace(NewRoutineName))
{
return;
}
var request = new RoutineUpsertRequest(NewRoutineName, SelectedExerciseIds.ToList());
var created = await Api.CreateRoutineAsync(UserContext.UserId, request);
if (created is not null)
{
RoutineList.Add(created);
RoutineList = RoutineList.OrderBy(r => r.Name).ToList();
NewRoutineName = string.Empty;
SelectedExerciseIds.Clear();
}
}
private void ToggleCreate()
{
ShowCreateRoutine = !ShowCreateRoutine;
}
private void GoToExercises()
{
Navigation.NavigateTo($"/{UserContext.UserId}/exercises");
}
private void StartEdit(RoutineDto routine)
{
EditingRoutine = routine;
EditingName = routine.Name;
EditingExerciseIds = routine.Exercises.Select(e => e.ExerciseId).ToHashSet();
}
private void CancelEdit()
{
EditingRoutine = null;
EditingName = string.Empty;
EditingExerciseIds.Clear();
}
private void ToggleEditExercise(int exerciseId)
{
if (!EditingExerciseIds.Add(exerciseId))
{
EditingExerciseIds.Remove(exerciseId);
}
}
private async Task SaveEditAsync()
{
if (EditingRoutine is null)
{
return;
}
var request = new RoutineUpsertRequest(EditingName, EditingExerciseIds.ToList());
var updated = await Api.UpdateRoutineAsync(UserContext.UserId, EditingRoutine.Id, request);
if (updated is not null)
{
var index = RoutineList.FindIndex(r => r.Id == EditingRoutine.Id);
if (index >= 0)
{
RoutineList[index] = updated;
RoutineList = RoutineList.OrderBy(r => r.Name).ToList();
}
}
CancelEdit();
}
private async Task StartRun(RoutineDto routine)
{
ActiveRun = routine;
var lastRun = await Api.GetLastRunAsync(UserContext.UserId, routine.Id);
RunEntries = routine.Exercises
.OrderBy(e => e.Order)
.Select(e =>
{
var last = lastRun.Entries.FirstOrDefault(x => x.ExerciseId == e.ExerciseId);
return new RoutineRunEntryDto(e.ExerciseId, last?.Weight ?? 0, false);
}).ToList();
}
private void ToggleRunCompleted(int exerciseId)
{
var entry = RunEntries.FirstOrDefault(e => e.ExerciseId == exerciseId);
if (entry is null)
{
return;
}
entry.Completed = !entry.Completed;
}
private string GetExerciseName(int exerciseId)
{
return ExerciseList.FirstOrDefault(e => e.Id == exerciseId)?.Name ?? "Exercise";
}
private void AbortRun()
{
ActiveRun = null;
RunEntries = new List<RoutineRunEntryDto>();
}
private async Task SaveRunAsync()
{
if (ActiveRun is null)
{
return;
}
var request = new RoutineRunRequest(RunEntries);
await Api.SaveRunAsync(UserContext.UserId, ActiveRun.Id, request);
ActiveRun = null;
RunEntries = new List<RoutineRunEntryDto>();
}
}

View File

@@ -0,0 +1,297 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Requests;
namespace ASTRAIN.Client.Pages;
public partial class Routines
{
/// <summary>
/// Optional user id from route.
/// </summary>
[Parameter]
public string? UserId { get; set; }
private List<ExerciseDto> ExerciseList { get; set; } = new();
private List<RoutineDto> RoutineList { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool ShowCreateRoutine { get; set; }
private string NewRoutineName { get; set; } = string.Empty;
private HashSet<int> SelectedExerciseIds { get; set; } = new();
private RoutineDto? EditingRoutine { get; set; }
private string EditingName { get; set; } = string.Empty;
private HashSet<int> EditingExerciseIds { get; set; } = new();
private RoutineDto? ActiveRun { get; set; }
private List<RoutineRunEntryDto> RunEntries { get; set; } = new();
/// <summary>
/// Available weight options for the routine run select input.
/// 1040 kg in 2.5 kg steps, then 40100 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/>
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
if (UserId != ensured)
{
Navigation.NavigateTo($"/{ensured}/routines", true);
return;
}
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
IsLoading = true;
ExerciseList = await Api.GetExercisesAsync(UserContext.UserId);
RoutineList = await Api.GetRoutinesAsync(UserContext.UserId);
IsLoading = false;
}
/// <summary>
/// Loads exercises and routines from the API and updates the UI state.
/// </summary>
private void ToggleExercise(int exerciseId)
{
if (!SelectedExerciseIds.Add(exerciseId))
{
SelectedExerciseIds.Remove(exerciseId);
}
}
/// <summary>
/// Toggles whether an exercise is selected when creating a routine.
/// </summary>
private async Task CreateRoutineAsync()
{
if (string.IsNullOrWhiteSpace(NewRoutineName))
{
return;
}
var request = new RoutineUpsertRequest(NewRoutineName, SelectedExerciseIds.ToList());
var created = await Api.CreateRoutineAsync(UserContext.UserId, request);
if (created is not null)
{
RoutineList.Add(created);
RoutineList = RoutineList.OrderBy(r => r.Name).ToList();
NewRoutineName = string.Empty;
SelectedExerciseIds.Clear();
}
/// <summary>
/// Creates a new routine with the selected exercises.
/// </summary>
}
private void ToggleCreate()
{
ShowCreateRoutine = !ShowCreateRoutine;
}
/// <summary>
/// Toggle visibility of the create-routine UI.
/// </summary>
private void GoToExercises()
{
Navigation.NavigateTo($"/{UserContext.UserId}/exercises");
}
/// <summary>
/// Navigate to the exercises page for the current user.
/// </summary>
private void StartEdit(RoutineDto routine)
{
EditingRoutine = routine;
EditingName = routine.Name;
EditingExerciseIds = routine.Exercises.Select(e => e.ExerciseId).ToHashSet();
}
/// <summary>
/// Begin editing the supplied routine.
/// </summary>
private void CancelEdit()
{
EditingRoutine = null;
EditingName = string.Empty;
EditingExerciseIds.Clear();
}
/// <summary>
/// Cancel the current routine edit and reset state.
/// </summary>
private void ToggleEditExercise(int exerciseId)
{
if (!EditingExerciseIds.Add(exerciseId))
{
EditingExerciseIds.Remove(exerciseId);
}
}
/// <summary>
/// Toggle whether an exercise is selected in the routine edit UI.
/// </summary>
private async Task SaveEditAsync()
{
if (EditingRoutine is null)
{
return;
}
var request = new RoutineUpsertRequest(EditingName, EditingExerciseIds.ToList());
var updated = await Api.UpdateRoutineAsync(UserContext.UserId, EditingRoutine.Id, request);
if (updated is not null)
{
var index = RoutineList.FindIndex(r => r.Id == EditingRoutine.Id);
if (index >= 0)
{
RoutineList[index] = updated;
RoutineList = RoutineList.OrderBy(r => r.Name).ToList();
}
}
CancelEdit();
}
/// <summary>
/// Save changes made to the currently edited routine.
/// </summary>
private async Task DeleteRoutineAsync(int routineId)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to delete this routine?");
if (!confirmed) return;
await Api.DeleteRoutineAsync(UserContext.UserId, routineId);
RoutineList.RemoveAll(r => r.Id == routineId);
}
/// <summary>
/// Delete the routine with the given id after confirmation.
/// </summary>
private async Task StartRun(RoutineDto routine)
{
ActiveRun = routine;
var lastRun = await Api.GetLastRunAsync(UserContext.UserId, routine.Id);
RunEntries = routine.Exercises
.OrderBy(e => e.Order)
.Select(e =>
{
var last = lastRun.Entries.FirstOrDefault(x => x.ExerciseId == e.ExerciseId);
return new RoutineRunEntryDto(e.ExerciseId, SnapToNearest(last?.Weight ?? WeightOptions[0]), false);
}).ToList();
}
/// <summary>
/// Start a routine run and prepare run entries.
/// </summary>
private void ToggleRunCompleted(int exerciseId)
{
var entry = RunEntries.FirstOrDefault(e => e.ExerciseId == exerciseId);
if (entry is null)
{
return;
}
entry.Completed = !entry.Completed;
}
/// <summary>
/// Toggle the completion state of a run entry.
/// </summary>
private string GetExerciseName(int exerciseId)
{
return ExerciseList.FirstOrDefault(e => e.Id == exerciseId)?.Name ?? "Exercise";
}
/// <summary>
/// Get the display name for an exercise id.
/// </summary>
private async Task AbortRun()
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to abort this routine run?");
if (!confirmed) return;
ActiveRun = null;
RunEntries = new List<RoutineRunEntryDto>();
}
/// <summary>
/// Abort the active routine run after confirmation.
/// </summary>
private async Task SaveRunAsync()
{
if (ActiveRun is null)
{
return;
}
var request = new RoutineRunRequest(RunEntries);
await Api.SaveRunAsync(UserContext.UserId, ActiveRun.Id, request);
ActiveRun = null;
RunEntries = new List<RoutineRunEntryDto>();
}
/// <summary>
/// Save the current routine run to the API.
/// </summary>
}

View File

@@ -1,57 +0,0 @@
@page "/weather"
@inject HttpClient Http
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
}
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

View File

@@ -6,7 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5014",
"applicationUrl": "http://+:5016",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -37,6 +37,11 @@ public class ApiClient
return await response.Content.ReadFromJsonAsync<ExerciseDto>();
}
public async Task DeleteExerciseAsync(string userId, int exerciseId)
{
await _http.DeleteAsync($"/api/users/{userId}/exercises/{exerciseId}");
}
public async Task<List<RoutineDto>> GetRoutinesAsync(string userId)
{
return await _http.GetFromJsonAsync<List<RoutineDto>>($"/api/users/{userId}/routines") ?? new List<RoutineDto>();
@@ -59,6 +64,11 @@ public class ApiClient
return await response.Content.ReadFromJsonAsync<RoutineDto>();
}
public async Task DeleteRoutineAsync(string userId, int routineId)
{
await _http.DeleteAsync($"/api/users/{userId}/routines/{routineId}");
}
public async Task<RoutineRunSummaryDto> GetLastRunAsync(string userId, int routineId)
{
return await _http.GetFromJsonAsync<RoutineRunSummaryDto>($"/api/users/{userId}/routines/{routineId}/last-run")

View File

@@ -7,6 +7,7 @@
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using ASTRAIN.Client
@using ASTRAIN.Client.Components
@using ASTRAIN.Client.Layout
@using ASTRAIN.Client.Services
@using ASTRAIN.Shared.Dtos

View File

@@ -1,3 +1,3 @@
{
"ApiBaseAddress": "http://localhost:5055"
"ApiBaseAddress": "http://10.20.30.99:5055"
}

View File

@@ -91,6 +91,8 @@ p {
.input-sm {
max-width: 110px;
appearance: none;
-webkit-appearance: none;
}
.primary {
@@ -139,10 +141,47 @@ p {
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 {
font-weight: 600;
}
.item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.item-subtitle {
color: #bdbdbd;
font-size: 0.85rem;
@@ -154,6 +193,74 @@ p {
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 {
display: flex;
gap: 0.5rem;
@@ -205,6 +312,120 @@ p {
gap: 1rem;
}
.app-shell {
min-height: 100vh;
background: #0b0b0b;
color: #f5f5f5;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding: 1.5rem 1.25rem 6rem;
width: 100%;
margin: 0 auto;
}
.brand-header {
text-align: center;
font-weight: 800;
font-size: 1.1rem;
padding: 1.25rem 0 0.25rem;
}
.brand-white {
color: #ffffff;
}
.brand-red {
color: #ff3b3b;
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 96px;
background: #141414;
border-top: 1px solid #2a2a2a;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 1.5rem;
gap: 1rem;
z-index: 10;
}
.nav-item {
color: #bdbdbd;
text-decoration: none;
font-weight: 700;
font-size: 0.9rem;
display: flex;
justify-content: center;
align-items: center;
height: 44px;
border: 1px solid transparent;
letter-spacing: 0.08em;
text-transform: uppercase;
width: 100%;
max-width: 160px;
margin: 0 auto;
justify-self: center;
text-align: center;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.nav-item.active {
color: #ffffff;
font-weight: 700;
border-color: #ff3b3b;
box-shadow: 0 0 0 2px rgba(255, 59, 59, 0.15);
background: rgba(255, 59, 59, 0.12);
}
.nav-item:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.06);
}
.nav-logo {
width: 80px;
height: 80px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
border-radius: 999px;
font-size: 0.7rem;
letter-spacing: 0.1em;
}
.nav-logo img {
width: 80px;
height: 80px;
border-radius: 10px;
object-fit: cover;
}
@media (min-width: 768px) {
.main-content {
padding: 2rem 2.5rem 7rem;
max-width: 720px;
margin: 0 auto;
}
.bottom-nav {
left: 50%;
transform: translateX(-50%);
max-width: 720px;
border-radius: 24px 24px 0 0;
}
}
.loading-screen img {
width: 96px;
height: 96px;

View File

@@ -8,7 +8,8 @@
<base href="/" />
<link rel="preload" id="webassembly" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="logo_square.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="logo_square.png" />
<link rel="manifest" href="manifest.json" />
<meta name="theme-color" content="#ff3b3b" />
@@ -29,6 +30,7 @@
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
<script src="js/kebabMenu.js"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>

View 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);
}
};
}
};