Compare commits
16 Commits
e81bf53def
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
d6a187c01a
|
|||
|
c00e36eff4
|
|||
|
55f10fba9f
|
|||
|
fae57c6c75
|
|||
|
00688eb548
|
|||
|
fd5abef3f6
|
|||
|
65a13539e0
|
|||
|
fd8395cc48
|
|||
|
8f2284e1fc
|
|||
|
5db6fee866
|
|||
|
01581b7a91
|
|||
|
56aacb0134
|
|||
|
990e67e88c
|
|||
|
81caddbea3
|
|||
|
2e69f0d5ef
|
|||
|
e690a649e8
|
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"
|
||||||
|
|||||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"git commit": true
|
||||||
|
},
|
||||||
|
"dotnet.defaultSolution": "ASTRAIN.slnx"
|
||||||
|
}
|
||||||
9
.vscode/tasks.json
vendored
9
.vscode/tasks.json
vendored
@@ -22,6 +22,15 @@
|
|||||||
],
|
],
|
||||||
"problemMatcher": "$msCompile",
|
"problemMatcher": "$msCompile",
|
||||||
"group": "build"
|
"group": "build"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Build & Push Docker",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "${workspaceFolder}\\.venv\\Scripts\\python.exe",
|
||||||
|
"args": [
|
||||||
|
"${workspaceFolder}/docker/build_and_push_image.py"
|
||||||
|
],
|
||||||
|
"problemMatcher": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
15
docker-compose.traefik.yml
Normal file
15
docker-compose.traefik.yml
Normal 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
|
||||||
26
docker/build_and_push_image.py
Normal file
26
docker/build_and_push_image.py
Normal 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
44
docker/build_image.py
Normal 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
57
docker/push_image.py
Normal 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())
|
||||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace ASTRAIN.Api.Endpoints;
|
namespace ASTRAIN.Api.Endpoints;
|
||||||
|
|
||||||
@@ -22,9 +23,10 @@ internal static class ExerciseEndpoints
|
|||||||
/// <returns>The same route group for chaining.</returns>
|
/// <returns>The same route group for chaining.</returns>
|
||||||
public static RouteGroupBuilder MapExerciseEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapExerciseEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db) =>
|
group.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
var items = await db.Exercises
|
var items = await db.Exercises
|
||||||
.Where(e => e.UserId == user.Id)
|
.Where(e => e.UserId == user.Id)
|
||||||
.OrderBy(e => e.Name)
|
.OrderBy(e => e.Name)
|
||||||
@@ -35,9 +37,10 @@ internal static class ExerciseEndpoints
|
|||||||
.WithSummary("List exercises")
|
.WithSummary("List exercises")
|
||||||
.WithDescription("Returns the exercises for the specified user.");
|
.WithDescription("Returns the exercises for the specified user.");
|
||||||
|
|
||||||
group.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db) =>
|
group.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
return Results.BadRequest("Name is required.");
|
return Results.BadRequest("Name is required.");
|
||||||
@@ -57,9 +60,10 @@ internal static class ExerciseEndpoints
|
|||||||
.WithSummary("Create exercise")
|
.WithSummary("Create exercise")
|
||||||
.WithDescription("Creates a new exercise for the specified user.");
|
.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) =>
|
group.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
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);
|
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
|
||||||
if (exercise is null)
|
if (exercise is null)
|
||||||
{
|
{
|
||||||
@@ -79,9 +83,10 @@ internal static class ExerciseEndpoints
|
|||||||
.WithSummary("Update exercise")
|
.WithSummary("Update exercise")
|
||||||
.WithDescription("Updates the name of an exercise for the specified user.");
|
.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) =>
|
group.MapDelete("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
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);
|
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
|
||||||
if (exercise is null)
|
if (exercise is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace ASTRAIN.Api.Endpoints;
|
namespace ASTRAIN.Api.Endpoints;
|
||||||
|
|
||||||
@@ -22,9 +23,10 @@ internal static class RoutineEndpoints
|
|||||||
/// <returns>The same route group for chaining.</returns>
|
/// <returns>The same route group for chaining.</returns>
|
||||||
public static RouteGroupBuilder MapRoutineEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapRoutineEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db) =>
|
group.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
var routines = await db.Routines
|
var routines = await db.Routines
|
||||||
.Include(r => r.Exercises)
|
.Include(r => r.Exercises)
|
||||||
.ThenInclude(re => re.Exercise)
|
.ThenInclude(re => re.Exercise)
|
||||||
@@ -46,9 +48,10 @@ internal static class RoutineEndpoints
|
|||||||
.WithSummary("List routines")
|
.WithSummary("List routines")
|
||||||
.WithDescription("Returns all routines for the specified user.");
|
.WithDescription("Returns all routines for the specified user.");
|
||||||
|
|
||||||
group.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) =>
|
group.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
var routine = await db.Routines
|
var routine = await db.Routines
|
||||||
.Include(r => r.Exercises)
|
.Include(r => r.Exercises)
|
||||||
.ThenInclude(re => re.Exercise)
|
.ThenInclude(re => re.Exercise)
|
||||||
@@ -72,9 +75,10 @@ internal static class RoutineEndpoints
|
|||||||
.WithSummary("Get routine")
|
.WithSummary("Get routine")
|
||||||
.WithDescription("Returns a specific routine and its exercises.");
|
.WithDescription("Returns a specific routine and its exercises.");
|
||||||
|
|
||||||
group.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db) =>
|
group.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
return Results.BadRequest("Name is required.");
|
return Results.BadRequest("Name is required.");
|
||||||
@@ -113,9 +117,10 @@ internal static class RoutineEndpoints
|
|||||||
.WithSummary("Create routine")
|
.WithSummary("Create routine")
|
||||||
.WithDescription("Creates a routine and associates exercises with it.");
|
.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) =>
|
group.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
var routine = await db.Routines
|
var routine = await db.Routines
|
||||||
.Include(r => r.Exercises)
|
.Include(r => r.Exercises)
|
||||||
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
|
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
|
||||||
@@ -160,9 +165,10 @@ internal static class RoutineEndpoints
|
|||||||
.WithSummary("Update routine")
|
.WithSummary("Update routine")
|
||||||
.WithDescription("Updates routine metadata and exercise ordering.");
|
.WithDescription("Updates routine metadata and exercise ordering.");
|
||||||
|
|
||||||
group.MapDelete("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) =>
|
group.MapDelete("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
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);
|
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
|
||||||
if (routine is null)
|
if (routine is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace ASTRAIN.Api.Endpoints;
|
namespace ASTRAIN.Api.Endpoints;
|
||||||
|
|
||||||
@@ -22,9 +23,10 @@ internal static class RoutineRunEndpoints
|
|||||||
/// <returns>The same route group for chaining.</returns>
|
/// <returns>The same route group for chaining.</returns>
|
||||||
public static RouteGroupBuilder MapRoutineRunEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapRoutineRunEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db) =>
|
group.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
var lastRun = await db.RoutineRuns
|
var lastRun = await db.RoutineRuns
|
||||||
.Include(rr => rr.Entries)
|
.Include(rr => rr.Entries)
|
||||||
.Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId)
|
.Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId)
|
||||||
@@ -47,9 +49,10 @@ internal static class RoutineRunEndpoints
|
|||||||
.WithSummary("Get last routine run")
|
.WithSummary("Get last routine run")
|
||||||
.WithDescription("Returns the most recent run summary for a routine.");
|
.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) =>
|
group.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
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);
|
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
|
||||||
if (routine is null)
|
if (routine is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ASTRAIN.Shared.Responses;
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace ASTRAIN.Api.Endpoints;
|
namespace ASTRAIN.Api.Endpoints;
|
||||||
|
|
||||||
@@ -19,17 +20,19 @@ internal static class UserEndpoints
|
|||||||
/// <returns>The same route group for chaining.</returns>
|
/// <returns>The same route group for chaining.</returns>
|
||||||
public static RouteGroupBuilder MapUserEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapUserEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/users/ensure", async (string? userId, AppDbContext db) =>
|
group.MapGet("/users/ensure", async (string? userId, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
return Results.Ok(new EnsureUserResponse(user.Id));
|
return Results.Ok(new EnsureUserResponse(user.Id));
|
||||||
})
|
})
|
||||||
.WithSummary("Ensure user")
|
.WithSummary("Ensure user")
|
||||||
.WithDescription("Ensures a user exists and returns the user id.");
|
.WithDescription("Ensures a user exists and returns the user id.");
|
||||||
|
|
||||||
group.MapGet("/users/{userId}", async (string userId, AppDbContext db) =>
|
group.MapGet("/users/{userId}", async (string userId, AppDbContext db, IConfiguration config) =>
|
||||||
{
|
{
|
||||||
var user = await UserProvisioning.EnsureUserAsync(db, userId);
|
var populateSampleData = config.GetValue("SampleData:Enabled", false);
|
||||||
|
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
|
||||||
return Results.Ok(new EnsureUserResponse(user.Id));
|
return Results.Ok(new EnsureUserResponse(user.Id));
|
||||||
})
|
})
|
||||||
.WithSummary("Get user")
|
.WithSummary("Get user")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using ASTRAIN.Api.Data;
|
using ASTRAIN.Api.Data;
|
||||||
using ASTRAIN.Api.Endpoints;
|
using ASTRAIN.Api.Endpoints;
|
||||||
|
using Microsoft.AspNetCore.StaticFiles;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -13,10 +15,6 @@ builder.Services.AddCors(options =>
|
|||||||
policy.AllowAnyOrigin()
|
policy.AllowAnyOrigin()
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod();
|
.AllowAnyMethod();
|
||||||
|
|
||||||
// policy.WithOrigins("http://localhost:5016", "http://:5016", "https://localhost:7252")
|
|
||||||
// .AllowAnyHeader()
|
|
||||||
// .AllowAnyMethod();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||||
@@ -42,7 +40,12 @@ if (app.Environment.IsDevelopment())
|
|||||||
// app.UseHttpsRedirection();
|
// app.UseHttpsRedirection();
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
ServeUnknownFileTypes = true
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "https://localhost:7049;http://localhost:5055",
|
"applicationUrl": "https://+:7049;http://+:5055",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ internal static class UserProvisioning
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="db">The application database context.</param>
|
/// <param name="db">The application database context.</param>
|
||||||
/// <param name="userId">Optional user id to validate or create.</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>
|
/// <returns>The existing or newly created user.</returns>
|
||||||
public static async Task<User> EnsureUserAsync(AppDbContext db, string? userId)
|
public static async Task<User> EnsureUserAsync(AppDbContext db, string? userId, bool populateSampleData)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId))
|
if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId))
|
||||||
{
|
{
|
||||||
@@ -29,6 +30,10 @@ internal static class UserProvisioning
|
|||||||
var created = new User { Id = userId };
|
var created = new User { Id = userId };
|
||||||
db.Users.Add(created);
|
db.Users.Add(created);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
if (populateSampleData)
|
||||||
|
{
|
||||||
|
await PopulateSampleDataAsync(db, created);
|
||||||
|
}
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +49,66 @@ internal static class UserProvisioning
|
|||||||
var user = new User { Id = newId };
|
var user = new User { Id = newId };
|
||||||
db.Users.Add(user);
|
db.Users.Add(user);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
if (populateSampleData)
|
||||||
|
{
|
||||||
|
await PopulateSampleDataAsync(db, user);
|
||||||
|
}
|
||||||
return 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>
|
/// <summary>
|
||||||
/// Determines whether a user id matches the expected format.
|
/// Determines whether a user id matches the expected format.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -4,5 +4,8 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SampleData": {
|
||||||
|
"Enabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,8 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Default": "Data Source=Data/astrain.db"
|
"Default": "Data Source=Data/astrain.db"
|
||||||
},
|
},
|
||||||
|
"SampleData": {
|
||||||
|
"Enabled": false
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -41,17 +41,19 @@
|
|||||||
<div class="list">
|
<div class="list">
|
||||||
@foreach (var exercise in ExerciseList)
|
@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)
|
@if (EditingId == exercise.Id)
|
||||||
{
|
{
|
||||||
<input class="input" @bind="EditingName" @bind:event="oninput" />
|
<input class="input" @bind="EditingName" @bind:event="oninput" />
|
||||||
|
<div class="item-actions">
|
||||||
<button class="primary" @onclick="() => SaveEditAsync(exercise.Id)">Save</button>
|
<button class="primary" @onclick="() => SaveEditAsync(exercise.Id)">Save</button>
|
||||||
<button class="ghost" @onclick="CancelEdit">Cancel</button>
|
<button class="ghost" @onclick="CancelEdit">Cancel</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="item-title">@exercise.Name</div>
|
<div class="item-title" style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden; width: 100%;">@exercise.Name</div>
|
||||||
<div class="actions">
|
<div class="item-actions">
|
||||||
<button class="ghost" @onclick="() => StartEdit(exercise)" aria-label="Edit exercise">✏️</button>
|
<button class="ghost" @onclick="() => StartEdit(exercise)" aria-label="Edit exercise">✏️</button>
|
||||||
<button class="ghost" @onclick="() => DeleteExerciseAsync(exercise.Id)" aria-label="Delete exercise">🗑️</button>
|
<button class="ghost" @onclick="() => DeleteExerciseAsync(exercise.Id)" aria-label="Delete exercise">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,102 +65,3 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
159
src/ASTRAIN.Client/Pages/Exercises.razor.cs
Normal file
159
src/ASTRAIN.Client/Pages/Exercises.razor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,23 +14,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
31
src/ASTRAIN.Client/Pages/Home.razor.cs
Normal file
31
src/ASTRAIN.Client/Pages/Home.razor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
@@ -129,188 +138,3 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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>();
|
|
||||||
}
|
|
||||||
|
|
||||||
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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
297
src/ASTRAIN.Client/Pages/Routines.razor.cs
Normal file
297
src/ASTRAIN.Client/Pages/Routines.razor.cs
Normal 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.
|
||||||
|
/// 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/>
|
||||||
|
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>
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"ApiBaseAddress": "http://localhost:5055"
|
"ApiBaseAddress": "http://10.20.30.99:5055"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -205,6 +312,120 @@ p {
|
|||||||
gap: 1rem;
|
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 {
|
.loading-screen img {
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: 96px;
|
height: 96px;
|
||||||
|
|||||||
@@ -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