diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9fdddf2 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy to .env and fill in values. Never commit .env to git. +ASPNETCORE_ENVIRONMENT=Production + +# PostgreSQL (docker-compose) +POSTGRES_PASSWORD=change-me +ConnectionStrings__fastgeography-db=Host=postgres;Port=5432;Database=fastgeography;Username=postgres;Password=change-me + +# Destination AI — Auto tries OpenAI chat completions first, then Ollama +DestinationAi__Provider=Auto +DestinationAi__Model=gpt-4o-mini +DestinationAi__ApiKey= +OPENAI_API_KEY= + +# Ollama fallback (docker compose --profile ollama up) +DestinationAi__OllamaModel=llama3.2:3b +DestinationAi__OllamaBaseUrl=http://ollama:11434/v1 + +# Optional explicit providers +# DestinationAi__Provider=OpenAI +# DestinationAi__Provider=Grok +# GROK_API_KEY= +# DestinationAi__Provider=Claude +# ANTHROPIC_API_KEY= + +# Geocoding (optional) +Geocoding__BingMaps__ApiKey= +Geocoding__GeoNames__Username= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2bbea82 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + name: Build, test, and security scan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build (Release) + run: dotnet build --no-restore --configuration Release + + - name: Unit tests + run: | + dotnet test tst/FastGeography.Tests.Unit \ + --no-build \ + --configuration Release \ + --logger "trx;LogFileName=unit-results.trx" \ + --collect:"XPlat Code Coverage" + + - name: Integration tests + run: | + dotnet test tst/FastGeography.Tests.Integration \ + --no-build \ + --configuration Release \ + --logger "trx;LogFileName=integration-results.trx" + env: + # Empty credentials — the fake geocoding service is substituted in tests + # so no real API calls are made regardless of which provider is configured. + Geocoding__Provider: "Nominatim" + Geocoding__GeoNames__Username: "" + Geocoding__BingMaps__ApiKey: "" + + - name: Publish test results + if: always() + uses: dorny/test-reporter@v1 + with: + name: Test results + path: "**/*.trx" + reporter: dotnet-trx + + - name: Security scan — vulnerable packages + run: dotnet list package --vulnerable --include-transitive + continue-on-error: true diff --git a/.gitignore b/.gitignore index 4d60fdd..c42e413 100644 --- a/.gitignore +++ b/.gitignore @@ -253,6 +253,11 @@ paket-files/ .idea/ *.sln.iml +# Environment secrets (use .env.example as template) +.env +.env.local +.env.*.local + # Kentico Required files - In-built Debug logging !Systems/CMS/Propay.LoggedOut/CMS/CMSModules/System/Debug/ !Systems/CMS/Propay.LoggedOut/CMS/CMSAdminControls/Debug/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..efbe314 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore src/FastGeography.Server/FastGeography.Server.csproj +RUN dotnet publish src/FastGeography.Server/FastGeography.Server.csproj -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "FastGeography.Server.dll"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8081707 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} + POSTGRES_DB: fastgeography + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + server: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: http://+:8080 + ConnectionStrings__fastgeography-db: Host=postgres;Port=5432;Database=fastgeography;Username=postgres;Password=${POSTGRES_PASSWORD:-change-me} + DestinationAi__Provider: ${DestinationAi__Provider:-Auto} + DestinationAi__Model: ${DestinationAi__Model:-gpt-4o-mini} + DestinationAi__ApiKey: ${DestinationAi__ApiKey:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + DestinationAi__OllamaModel: ${DestinationAi__OllamaModel:-llama3.2:3b} + DestinationAi__OllamaBaseUrl: ${DestinationAi__OllamaBaseUrl:-http://ollama:11434/v1} + DestinationAi__BaseUrl: ${DestinationAi__BaseUrl:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + GROK_API_KEY: ${GROK_API_KEY:-} + depends_on: + postgres: + condition: service_healthy + + # Optional local LLM. First run: docker compose exec ollama ollama pull llama3.2:3b + # Requires ~8 GB RAM for llama3.2:3b on CPU. Do not expose port 11434 publicly in production. + ollama: + image: ollama/ollama:latest + profiles: ["ollama"] + volumes: + - ollama-data:/root/.ollama + # Internal only — server reaches ollama via Docker network when BaseUrl=http://ollama:11434/v1 + +volumes: + postgres-data: + ollama-data: diff --git a/src/FastGeography.AppHost/FastGeography.AppHost.csproj b/src/FastGeography.AppHost/FastGeography.AppHost.csproj index 5b346f8..d9c5d15 100644 --- a/src/FastGeography.AppHost/FastGeography.AppHost.csproj +++ b/src/FastGeography.AppHost/FastGeography.AppHost.csproj @@ -13,10 +13,10 @@ + - diff --git a/src/FastGeography.AppHost/Program.cs b/src/FastGeography.AppHost/Program.cs index 0c1498d..2ebe11c 100644 --- a/src/FastGeography.AppHost/Program.cs +++ b/src/FastGeography.AppHost/Program.cs @@ -1,7 +1,45 @@ var builder = DistributedApplication.CreateBuilder(args); -var server = builder.AddProject("fastgeography-api"); -var client = builder.AddProject("fastgeography-client") - .WithReference(server); +// Hosted Blazor WASM: the Server serves both the UI and the API on one origin. +// Do not AddProject the Client — that would start a second WASM host and break same-origin HttpClient. -builder.Build().Run(); \ No newline at end of file +// Optional secrets — set via AppHost user-secrets (never commit keys). +// dotnet user-secrets set "Parameters:openai-apikey" "sk-..." --project src/FastGeography.AppHost +var geoNamesUsername = builder.AddParameter("geonames-username", secret: true); +var bingMapsApiKey = builder.AddParameter("bingmaps-apikey", secret: true); +var openAiApiKey = builder.AddParameter("openai-apikey", secret: true); +var anthropicApiKey = builder.AddParameter("anthropic-apikey", secret: true); +var grokApiKey = builder.AddParameter("grok-apikey", secret: true); + +// Default chain: OpenAI chat completions first, Ollama second. +// Override with Parameters:destination-ai-provider. +var destinationAiProvider = builder.AddParameter("destination-ai-provider", "Auto"); + +// Local LLM fallback. First run: docker exec -it ollama pull llama3.2:3b +// Needs ~8 GB RAM for llama3.2:3b on CPU. +var ollama = builder.AddContainer("ollama", "ollama/ollama", tag: "latest") + .WithHttpEndpoint(port: 11434, targetPort: 11434, name: "http") + .WithVolume("ollama-models", "/root/.ollama"); + +var postgres = builder.AddPostgres("postgres") + .WithPgAdmin(); + +var db = postgres.AddDatabase("fastgeography-db"); + +var server = builder.AddProject("fastgeography-server") + .WithExternalHttpEndpoints() + .WithHttpHealthCheck("/alive") + .WithEnvironment("Geocoding__GeoNames__Username", geoNamesUsername) + .WithEnvironment("Geocoding__BingMaps__ApiKey", bingMapsApiKey) + .WithEnvironment("DestinationAi__Provider", destinationAiProvider) + .WithEnvironment("DestinationAi__ApiKey", openAiApiKey) + .WithEnvironment("DestinationAi__OllamaBaseUrl", $"{ollama.GetEndpoint("http")}/v1") + .WithEnvironment("OpenAI__ApiKey", openAiApiKey) + .WithEnvironment("OPENAI_API_KEY", openAiApiKey) + .WithEnvironment("ANTHROPIC_API_KEY", anthropicApiKey) + .WithEnvironment("GROK_API_KEY", grokApiKey) + .WithReference(db) + .WaitFor(db) + .WaitFor(ollama); + +builder.Build().Run(); diff --git a/src/FastGeography.AppHost/appsettings.json b/src/FastGeography.AppHost/appsettings.json index 31c092a..ad1ce86 100644 --- a/src/FastGeography.AppHost/appsettings.json +++ b/src/FastGeography.AppHost/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } + }, + "Parameters": { + "geonames-username": "", + "bingmaps-apikey": "" + }, + "ConnectionStrings": { + "fastgeography-db": "Host=localhost;Port=5432;Database=fastgeography;Username=postgres;Password=postgres" } } diff --git a/src/FastGeography.Client/App.razor b/src/FastGeography.Client/App.razor index 6e4e127..948a307 100644 --- a/src/FastGeography.Client/App.razor +++ b/src/FastGeography.Client/App.razor @@ -1,13 +1,27 @@ - - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
+@inject IStringLocalizer L + + + + + + + @* Show nothing while the async auth check is in flight so we don't + prematurely redirect authenticated users to the login page. *@ + + + + + + + + + + + Not found + +

@L["App_NotFound"]

+
+
+
+
diff --git a/src/FastGeography.Client/Auth/CookieAuthenticationStateProvider.cs b/src/FastGeography.Client/Auth/CookieAuthenticationStateProvider.cs new file mode 100644 index 0000000..91ec276 --- /dev/null +++ b/src/FastGeography.Client/Auth/CookieAuthenticationStateProvider.cs @@ -0,0 +1,92 @@ +namespace FastGeography.Client.Auth; + +using System.Net.Http.Json; +using System.Security.Claims; + +using FastGeography.Shared.Dtos; + +using Microsoft.AspNetCore.Components.Authorization; + +public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvider +{ + private static readonly AuthenticationState Anonymous = + new(new ClaimsPrincipal(new ClaimsIdentity())); + + private readonly HttpClient _http; + private UserInfoResponse? _cachedUser; + + public CookieAuthenticationStateProvider(HttpClient http) => _http = http; + + public override async Task GetAuthenticationStateAsync() + { + // Return cached state when available to avoid an HTTP round-trip on every + // component render and to prevent the auth state from appearing as + // "anonymous" while an async check is in flight. + if (_cachedUser is not null) + return BuildState(_cachedUser); + + try + { + _cachedUser = await _http.GetFromJsonAsync("api/auth/userinfo"); + } + catch + { + _cachedUser = null; + } + + return _cachedUser is null ? Anonymous : BuildState(_cachedUser); + } + + public async Task LoginAsync(string email, string password) + { + var response = await _http.PostAsJsonAsync( + "api/auth/login", new LoginRequest(email, password)); + + if (!response.IsSuccessStatusCode) return false; + + // Clear the cache so GetAuthenticationStateAsync fetches fresh data. + _cachedUser = null; + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + return true; + } + + public async Task<(bool Success, string? Error)> RegisterAsync( + string email, string password, string displayName) + { + var response = await _http.PostAsJsonAsync( + "api/auth/register", new RegisterRequest(email, password, displayName)); + + if (response.IsSuccessStatusCode) + { + // Auto-login after registration by calling login + await LoginAsync(email, password); + return (true, null); + } + + var body = await response.Content.ReadFromJsonAsync(); + return (false, string.Join("; ", body?.Errors ?? ["Registration failed."])); + } + + public async Task LogoutAsync() + { + await _http.PostAsync("api/auth/logout", null); + _cachedUser = null; + NotifyAuthenticationStateChanged(Task.FromResult(Anonymous)); + } + + public UserInfoResponse? CurrentUser => _cachedUser; + + private static AuthenticationState BuildState(UserInfoResponse user) + { + var identity = new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, user.UserId), + new Claim(ClaimTypes.Name, user.DisplayName), + new Claim(ClaimTypes.Email, user.Email), + ], "cookie"); + + return new AuthenticationState(new ClaimsPrincipal(identity)); + } + + private sealed record ErrorBody(List? Errors); +} diff --git a/src/FastGeography.Client/Auth/CookieHandler.cs b/src/FastGeography.Client/Auth/CookieHandler.cs new file mode 100644 index 0000000..15bd929 --- /dev/null +++ b/src/FastGeography.Client/Auth/CookieHandler.cs @@ -0,0 +1,14 @@ +namespace FastGeography.Client.Auth; + +using Microsoft.AspNetCore.Components.WebAssembly.Http; + +public sealed class CookieHandler : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); + request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest"); + return base.SendAsync(request, cancellationToken); + } +} diff --git a/src/FastGeography.Client/FastGeography.Client.csproj b/src/FastGeography.Client/FastGeography.Client.csproj index ca15cd2..c57a393 100644 --- a/src/FastGeography.Client/FastGeography.Client.csproj +++ b/src/FastGeography.Client/FastGeography.Client.csproj @@ -4,13 +4,17 @@ net8.0 enable enable + en + true - + + + diff --git a/src/FastGeography.Client/Pages/Counter.razor b/src/FastGeography.Client/Pages/Counter.razor deleted file mode 100644 index fcba10d..0000000 --- a/src/FastGeography.Client/Pages/Counter.razor +++ /dev/null @@ -1,18 +0,0 @@ -@page "/counter" - -Counter ddd - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/src/FastGeography.Client/Pages/GameTable.razor b/src/FastGeography.Client/Pages/GameTable.razor index 9257666..865657e 100644 --- a/src/FastGeography.Client/Pages/GameTable.razor +++ b/src/FastGeography.Client/Pages/GameTable.razor @@ -1,203 +1,267 @@ -@page "/fastgeography" -@using BingMapsRESTToolkit; -@using System.Net.Http.Json; -@using System.Linq; -@using FastGeography.Shared; -@inject HttpClient HttpClient; +@page "/" +@page "/fastgeography" +@implements IDisposable +@using System.Net.Http.Json +@using System.Linq +@using FastGeography.Shared +@inject HttpClient HttpClient +@inject ILogger Logger +@inject GameLanguageState Language +@inject IStringLocalizer L
- -
- +
+
+ @if (isChecking) + { +
+ + @L["Game_CheckingAnswers"] +
+ } + @if (games.Any()) + { +
+ +
+ }
- @if (games == null) + @if (!string.IsNullOrEmpty(errorMessage)) { -

Loading...

+ } - else if (!games.Any()) + + @if (!games.Any()) { -

No game started. Please start one.

+

@L["Game_NoGameStarted"]

} - else + + @if (games.Any()) { - -
- @if (totalPoints > 0) + + + + + + + + + + + + + + + @foreach (var game in games) + { + + } + +
@L["Col_Letter"]@L["Col_City"]@L["Col_Village"]@L["Col_Country"]@L["Col_River"]@L["Col_Mountain"]@L["Col_Check"]
+
+ + @if (totalPoints > ScoringRules.AchievementThreshold) { -
-

@encourageMessage

-

🎉 Amazing Explorer! You earned the @GetBadge() badge 🎉

- Achievement Badge + var badge = BadgeCalculator.Calculate(totalPoints); + var badgeName = L[$"Badge_{badge}"].Value; +
+
+

@encourageMessage

+

@string.Format(L["Game_AmazingExplorer"]!, badgeName)

+
+ @badgeName
} - - - - - - - - - - - - - - @foreach (var game in games) - { - - } - -
LetterCityVillageCountryRiverMountainTime[s]
+ } +
+ +@code { + private IDictionary games = new Dictionary(); + private int totalPoints = 0; + private string? encourageMessage; + private string? errorMessage; + private bool isStarted = false; + private bool isChecking = false; + private Countdown countdown = null!; + private string _langCode = "en"; + private List _storyPlaces = []; + + protected override async Task OnInitializedAsync() + { + await Language.EnsureLoadedAsync(); + _langCode = Language.Code; + Language.Changed += OnLanguageChanged; } - @code { - private string bingMapsKey = "AvgAK8EVgx50WkOB6cyA8ckUM5ku4U3kGJvxthKwE75_S4-c-XlTP82kUom8baQk"; - public IDictionary games { get; set; } = new Dictionary(); - private int totalPoints = 0; - private string encourageMessage = ""; - private int totalGames = 10; - private int gamesPlayed = 0; - private bool isStarted = false; - private Countdown countdown; - private IDictionary> locations = new Dictionary>(); - - private void StartNewGame() + private void OnLanguageChanged() + { + _langCode = Language.Code; + InvokeAsync(StateHasChanged); + } + + private async Task StartNewGame() + { + await Language.EnsureLoadedAsync(); + _langCode = Language.Code; + isStarted = true; + var language = GameLanguageExtensions.Parse(_langCode); + var letter = Alphabet.RandomLetter(language); + var key = Guid.NewGuid(); + + games.Add(key, new Game { - totalGames++; - isStarted = true; - Random random = new Random(); - var letter = (char)('A' + random.Next(0, 26)); - var key = Guid.NewGuid(); + Id = key, + DatePlayed = DateTime.Now, + Letter = letter, + City = new GameLocation { Answer = string.Empty, LocationType = LocationType.City }, + Country = new GameLocation { Answer = string.Empty, LocationType = LocationType.Country }, + Village = new GameLocation { Answer = string.Empty, LocationType = LocationType.Village }, + River = new GameLocation { Answer = string.Empty, LocationType = LocationType.River }, + Mountain = new GameLocation { Answer = string.Empty, LocationType = LocationType.Mountain }, + IsFinished = false + }); - games.Add(key, new Game() - { - Id = key, - DatePlayed = DateTime.Now, - Letter = letter, - City = new GameLocation() { Answer = string.Empty, LocationType = LocationType.City }, - Country = new GameLocation() { Answer = string.Empty, LocationType = LocationType.Country }, - Village = new GameLocation() { Answer = string.Empty, LocationType = LocationType.Village }, - River = new GameLocation() { Answer = string.Empty, LocationType = LocationType.River }, - Mountain = new GameLocation() { Answer = string.Empty, LocationType = LocationType.Mountain }, - IsFinished = false - }); - - countdown.Start(60); - } + countdown.Start(ScoringRules.DefaultTimerSeconds); + } - protected override async Task OnInitializedAsync() + private async Task OnTimerExpired() + { + var activeGame = games.Values.FirstOrDefault(g => !g.IsFinished); + if (activeGame is not null) { - //TODO: load the games that the user has played. Load them from the Redis cache. - //games = await Http.GetFromJsonAsync>("sample-data/fastgeography.json"); + await CheckAnswers(activeGame); } + } - async Task CheckAnswers(Game game) + private async Task CheckAnswers(Game game) + { + countdown.Stop(); + game.SecondsPlayed = countdown.ElapsedSeconds; + game.IsFinished = true; + isChecking = true; + errorMessage = null; + StateHasChanged(); + + try { - countdown.Stop(); - - game.SecondsPlayed = countdown.ElapsedSeconds; - game.IsFinished = true; - game.City.Points = await CheckGeoLocation(game.City, game.Letter); - game.Village.Points = await CheckGeoLocation(game.Village, game.Letter); - game.Country.Points = await CheckGeoLocation(game.Country, game.Letter); - game.River.Points = await CheckGeoLocation(game.River, game.Letter); - game.Mountain.Points = await CheckGeoLocation(game.Mountain, game.Letter); - - totalPoints = totalPoints + game.TotalPoints; - encourageMessage = GetEncouragingMessage(game.TotalPoints); - games.Remove(game.Id); - games.Add(game.Id, game); - - isStarted = !game.IsFinished; - } + var cityTask = ValidateLocation(game.City, game.Letter); + var villageTask = ValidateLocation(game.Village, game.Letter); + var countryTask = ValidateLocation(game.Country, game.Letter); + var riverTask = ValidateLocation(game.River, game.Letter); + var mountainTask = ValidateLocation(game.Mountain, game.Letter); + await Task.WhenAll(cityTask, villageTask, countryTask, riverTask, mountainTask); - private async Task CheckGeoLocation(GameLocation? location, char gameLetter) + game.City!.Points = cityTask.Result; + game.Village!.Points = villageTask.Result; + game.Country!.Points = countryTask.Result; + game.River!.Points = riverTask.Result; + game.Mountain!.Points = mountainTask.Result; + } + finally { - var points = 0; + isChecking = false; + } - if (string.IsNullOrEmpty(location.Answer)) - return 0;//TODO: add a penalty for empty answer + totalPoints += game.TotalPoints; + encourageMessage = GetEncouragingMessage(game.TotalPoints); - if (!location.Answer.StartsWith(gameLetter)) - return -10;//TODO: add a penalty for wrong letter + _storyPlaces = MergeStoryPlaces(_storyPlaces, BuildStoryRequests(game)); - //var response = await HttpClient.GetAsync($"bingmaps/{location}"); - var response = await HttpClient.GetAsync($"bingmaps/{location.Answer}/{location.LocationType}"); + games.Remove(game.Id); + games.Add(game.Id, game); + isStarted = false; + } - if (response.IsSuccessStatusCode) - { - var result = await response.Content.ReadAsStringAsync(); - var resultData = result.Split(":"); - int.TryParse(resultData[1], out points); - if (resultData.Length > 2) - { - location.Coordinates = resultData[2]; - } - } - else - { - // Handle the error here... - } + private async Task ValidateLocation(GameLocation? location, char gameLetter) + { + if (location is null || string.IsNullOrWhiteSpace(location.Answer)) + return ScoringRules.EmptyPoints; - return points; - } + if (!Alphabet.StartsWithLetter(location.Answer, gameLetter)) + return ScoringRules.WrongLetterPoints; - private Badge GetBadge() + try { - if (totalPoints > 0 && totalPoints <= 100) - return Badge.Junior; - else if (totalPoints > 100 && totalPoints <= 200) - return Badge.Cadet; - else if (totalPoints > 200 && totalPoints <= 300) - return Badge.Explorer; - else if (totalPoints > 300 && totalPoints <= 400) - return Badge.Traveller; - else if (totalPoints > 400 && totalPoints <= 500) - return Badge.Jumper; - else if (totalPoints > 500 && totalPoints <= 600) - return Badge.EarthSurfer; - else if (totalPoints > 600 && totalPoints <= 700) - return Badge.EarthConqueror; - else if (totalPoints > 700 && totalPoints <= 800) - return Badge.SolarSpectre; - else if (totalPoints > 800 && totalPoints <= 900) - return Badge.GalacticSurfer; - else if (totalPoints > 900 && totalPoints <= 1000) - return Badge.GalacticConqueror; - - return Badge.Junior; - } + var result = await HttpClient.GetFromJsonAsync( + $"geocode/{Uri.EscapeDataString(location.Answer)}/{location.LocationType}?lang={_langCode}"); - private string GetBadgeImage() + if (result is null) + return ScoringRules.InvalidPoints; + + location.Coordinates = result.Coordinates; + return result.Points; + } + catch (Exception ex) { - var rating = (int)GetBadge(); - return $"images/badge-{rating}.png"; + Logger.LogError(ex, "Failed to validate {LocationType} answer '{Answer}'", + location.LocationType, location.Answer); + errorMessage = L["Game_ValidationError"]; + return ScoringRules.EmptyPoints; } + } - private string GetEncouragingMessage(int points) + private static List MergeStoryPlaces(List existing, List newPlaces) + { + var merged = new List(existing); + foreach (var place in newPlaces) { - if (points >= 80) return "🌟 Outstanding! You're a Geography Genius!"; - if (points >= 60) return "🎈 Amazing job! Keep exploring!"; - if (points >= 40) return "👍 Good work! You're learning fast!"; - if (points >= 20) return "😊 Nice try! Let's discover more places!"; - return "🌍 Every game makes you smarter! Try again!"; + if (!merged.Any(p => + p.Name.Equals(place.Name, StringComparison.OrdinalIgnoreCase) && p.Type == place.Type)) + { + merged.Add(place); + } } + + return merged; } -
\ No newline at end of file + + private List BuildStoryRequests(Game game) + { + var locations = new[] + { + game.City, game.Village, game.Country, game.River, game.Mountain + }; + return locations + .Where(l => l is not null && l.Points == ScoringRules.ValidPoints && !string.IsNullOrWhiteSpace(l.Answer)) + .Select(l => new StoryRequest(l!.Answer!, l.LocationType, l.Coordinates, _langCode)) + .ToList(); + } + + private static string GetBadgeImagePath(int points) => + $"images/badge-{(int)BadgeCalculator.Calculate(points)}.svg"; + + private string GetEncouragingMessage(int points) + { + if (points >= 80) return L["Game_Encourage_Outstanding"]; + if (points >= 60) return L["Game_Encourage_Amazing"]; + if (points >= 40) return L["Game_Encourage_Good"]; + if (points >= 20) return L["Game_Encourage_NiceTry"]; + return L["Game_Encourage_TryAgain"]; + } + + public void Dispose() => Language.Changed -= OnLanguageChanged; +} diff --git a/src/FastGeography.Client/Pages/Index.razor b/src/FastGeography.Client/Pages/Index.razor deleted file mode 100644 index 6085c4a..0000000 --- a/src/FastGeography.Client/Pages/Index.razor +++ /dev/null @@ -1,9 +0,0 @@ -@page "/" - -Index - -

Hello, world!

- -Welcome to your new app. - - diff --git a/src/FastGeography.Client/Pages/Login.razor b/src/FastGeography.Client/Pages/Login.razor new file mode 100644 index 0000000..3927fb4 --- /dev/null +++ b/src/FastGeography.Client/Pages/Login.razor @@ -0,0 +1,85 @@ +@page "/login" +@inject CookieAuthenticationStateProvider Auth +@inject NavigationManager Nav +@inject IStringLocalizer L + +@L["Login_PageTitle"] + +
+
+

@L["Login_Heading"]

+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + + + + +
+ + + +
+ +
+ + + +
+ + +
+ +

+ @L["Login_NoAccount"] @L["Login_RegisterLink"] +

+
+
+ +@code { + private readonly LoginModel _model = new(); + private string? _error; + private bool _busy; + private string? _returnUrl; + + protected override void OnInitialized() + { + var query = new Uri(Nav.Uri).Query; + foreach (var part in query.TrimStart('?').Split('&')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0] == "returnUrl") + { + _returnUrl = Uri.UnescapeDataString(kv[1]); + break; + } + } + } + + private async Task DoLogin() + { + _busy = true; + _error = null; + var ok = await Auth.LoginAsync(_model.Email, _model.Password); + _busy = false; + + if (ok) Nav.NavigateTo(string.IsNullOrWhiteSpace(_returnUrl) ? "/" : _returnUrl); + else _error = L["Login_InvalidCredentials"]; + } + + private sealed class LoginModel + { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Email { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + public string Password { get; set; } = string.Empty; + } +} diff --git a/src/FastGeography.Client/Pages/MultiplayerGame.razor b/src/FastGeography.Client/Pages/MultiplayerGame.razor new file mode 100644 index 0000000..cb986b5 --- /dev/null +++ b/src/FastGeography.Client/Pages/MultiplayerGame.razor @@ -0,0 +1,456 @@ +@page "/multiplayer/{RoomCode}" +@attribute [Authorize] +@implements IAsyncDisposable +@inject HttpClient Http +@inject CookieAuthenticationStateProvider Auth +@inject NavigationManager Nav +@inject ActiveMultiplayerRoomState ActiveRoom +@inject ILogger Logger +@inject IStringLocalizer L + +@using FastGeography.Shared +@using FastGeography.Shared.Dtos + +Room @RoomCode – FastGeography + +
+ + @* ── Header bar ── *@ +
+

@L["MpGame_Room"] @RoomCode

+ + + @(_languageCode == "mk" ? L["MpGame_LangMk"].Value : L["MpGame_LangEn"].Value) + + + @if (_roundsCompleted > 0 || _roundActive) + { + + @string.Format(L["MpGame_RoundOf"]!, _roundActive ? _roundNumber : _roundsCompleted, ScoringRules.SetSize) + + } + + @if (_mySetPoints > 0) + { + @string.Format(L["MpGame_SetTotal"]!, _mySetPoints) + } + + + + +
+ + @if (!string.IsNullOrEmpty(_error)) + { +
+ @_error + +
+ } + + @* ── Players + controls ── *@ +
+
+
+
+
@L["MpGame_Players"]
+ @foreach (var p in _players) + { +
+ + @if (p.DisplayName == _hostName) { 👑 } + @p.DisplayName + @if (_submittedNames.Contains(p.DisplayName)) { } +
+ } +
+
+
+ +
+ @if (_isHost && !_roundActive && _connected && !_setComplete) + { + + } + @if (_isHost && _setComplete && !_roundActive && _connected) + { + + } + @if (!_connected && !_connecting) + { + + @L["MpGame_Disconnected"] + + + } +
+
+ + @* ── Personal answers table (casual-play style) ── *@ + @if (_myRows.Count > 0 || _roundActive) + { + + + + + + + + + + + + + + + @foreach (var row in _myRows) + { + var g = ToGame(row); + + } + @if (_roundActive && !_submitted) + { + + + + + + + + + + } + @if (_roundActive && _submitted) + { + + + + + + } + +
@L["Col_Letter"]@L["Col_City"]@L["Col_Village"]@L["Col_Country"]@L["Col_River"]@L["Col_Mountain"]@L["Col_Check"]
@_letter + +
@_letter@L["MpGame_WaitingPlayers"]
+
+ } + + @if (_setComplete && _myRows.Count > 0) + { +
+ @L["MpGame_SetComplete"] @string.Format(L["MpGame_SetCompleteDetail"]!, _mySetPoints, ScoringRules.SetSize) + @if (_isHost) { @L["MpGame_PlayAnotherHint"] } +
+ } + + @* ── All-player round results ── *@ + @if (_results is not null) + { +
+
@string.Format(L["MpGame_RoundResults"]!, _roundsCompleted)
+ + + + + + + + + @foreach (var r in _results.Results) + { + + + + + @foreach (var d in r.Details) + { + + } + + } + +
#@L["Col_Player"]@L["Col_Points"]@L["Col_City"]@L["Col_Village"]@L["Col_Country"]@L["Col_River"]@L["Col_Mountain"]
@r.Rank +
+ + @r.PlayerName +
+
@r.TotalPoints + @(string.IsNullOrEmpty(d.Answer) ? "–" : d.Answer) + @d.Points @L["MpGame_Pts"] +
+
+ } +
+ +@code { + [Parameter] public string RoomCode { get; set; } = string.Empty; + + private HubConnection? _hub; + private bool _connected, _connecting; + private bool _isHost; + private string _hostName = string.Empty; + private List _players = []; + private HashSet _submittedNames = []; + + private bool _roundActive, _submitted; + private int _roundNumber; + private int _roundsCompleted; + private bool _setComplete; + private char _letter; + private Countdown _countdown = null!; + + private string? _city, _village, _country, _river, _mountain; + private RoundResultsMessage? _results; + private string? _error; + private List _storyPlaces = []; + + private readonly List _myRows = []; + private string _myDisplayName = string.Empty; + private string _languageCode = "en"; + private int _mySetPoints => _myRows.Sum(r => r.Details.Sum(d => d.Points)); + + protected override async Task OnInitializedAsync() + { + _myDisplayName = Auth.CurrentUser?.DisplayName ?? string.Empty; + await Connect(); + } + + private async Task Connect() + { + _connecting = true; + _hub = new HubConnectionBuilder() + .WithUrl(Nav.ToAbsoluteUri("/hubs/game")) + .WithAutomaticReconnect() + .Build(); + + _hub.On("RoomJoined", state => + { + _players = state.Players; + _hostName = state.HostName; + _roundActive = state.RoundActive; + _roundsCompleted = state.RoundsCompletedInSet; + _setComplete = state.SetComplete; + _languageCode = state.LanguageCode; + _myRows.Clear(); + _myRows.AddRange(state.MyCompletedRounds); + _isHost = !string.IsNullOrEmpty(_myDisplayName) && state.HostName == _myDisplayName; + InvokeAsync(async () => + { + await ActiveRoom.SetAsync(RoomCode); + StateHasChanged(); + }); + }); + + _hub.On("PlayerJoined", player => + { + if (!_players.Any(p => p.UserId == player.UserId)) + _players.Add(player); + InvokeAsync(StateHasChanged); + }); + + _hub.On("PlayerLeft", name => + { + _players.RemoveAll(p => p.DisplayName == name); + InvokeAsync(StateHasChanged); + }); + + _hub.On("HostChanged", newHostName => + { + _hostName = newHostName; + _isHost = !string.IsNullOrEmpty(_myDisplayName) && newHostName == _myDisplayName; + InvokeAsync(StateHasChanged); + }); + + _hub.On("LeftRoom", () => + { + InvokeAsync(async () => + { + await ActiveRoom.ClearAsync(); + Nav.NavigateTo("/multiplayer"); + }); + }); + + _hub.On("RoundStarted", msg => + { + _letter = msg.Letter; + _city = _village = _country = _river = _mountain = string.Empty; + _roundActive = true; + _submitted = false; + _roundNumber = msg.RoundNumber; + _submittedNames.Clear(); + var seconds = Math.Max(1, (int)(msg.EndsAt - DateTime.UtcNow).TotalSeconds); + InvokeAsync(() => + { + _countdown?.Start(seconds); + StateHasChanged(); + }); + }); + + _hub.On("AnswersAccepted", () => + { + _submitted = true; + InvokeAsync(StateHasChanged); + }); + + _hub.On("RoundResults", results => + { + _results = results; + _roundActive = false; + _submitted = false; + _roundsCompleted = results.RoundsCompletedInSet; + _setComplete = results.SetComplete; + _submittedNames = results.Results.Select(r => r.PlayerName).ToHashSet(); + + var myResult = results.Results.FirstOrDefault(r => r.PlayerName == _myDisplayName); + if (myResult is not null) + { + _myRows.Add(new CompletedRoundRow(_letter, myResult.Details)); + var newPlaces = myResult.Details + .Where(d => d.Points == ScoringRules.ValidPoints && !string.IsNullOrWhiteSpace(d.Answer)) + .Select(d => new StoryRequest(d.Answer!, d.Type, d.Coordinates, _languageCode)) + .ToList(); + _storyPlaces = MergeStoryPlaces(_storyPlaces, newPlaces); + } + + InvokeAsync(() => { _countdown?.Stop(); StateHasChanged(); }); + }); + + _hub.On("NewSetStarted", () => + { + _myRows.Clear(); + _storyPlaces = []; + _roundsCompleted = 0; + _setComplete = false; + _results = null; + _roundNumber = 0; + InvokeAsync(StateHasChanged); + }); + + _hub.On("Error", msg => + { + _error = msg; + if (msg.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + InvokeAsync(async () => + { + await ActiveRoom.ClearAsync(); + StateHasChanged(); + }); + } + else + { + InvokeAsync(StateHasChanged); + } + }); + + _hub.Reconnected += _ => + { + InvokeAsync(async () => await _hub.SendAsync("JoinRoom", RoomCode)); + return Task.CompletedTask; + }; + + try + { + await _hub.StartAsync(); + _connected = true; + await _hub.SendAsync("JoinRoom", RoomCode); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to connect to game hub"); + _error = L["MpGame_ConnectError"]; + } + finally + { + _connecting = false; + StateHasChanged(); + } + } + + private async Task StartRound() + { + if (_hub is null) return; + await _hub.SendAsync("StartRound", RoomCode); + } + + private async Task StartNewSet() + { + if (_hub is null) return; + await _hub.SendAsync("StartNewSet", RoomCode); + } + + private async Task LeaveRoom() + { + if (_hub is null) return; + await _hub.SendAsync("LeaveRoom", RoomCode); + } + + private async Task OnTimerExpired() => await SubmitAnswers(); + + private async Task SubmitAnswers() + { + if (_hub is null || !_roundActive || _submitted) return; + var req = new SubmitAnswersRequest(_city, _village, _country, _river, _mountain, _languageCode); + await _hub.SendAsync("SubmitAnswers", RoomCode, req); + } + + private static Game ToGame(CompletedRoundRow row) + { + static GameLocation Loc(List d, LocationType t) => new() + { + LocationType = t, + Answer = d.FirstOrDefault(x => x.Type == t)?.Answer, + Points = d.FirstOrDefault(x => x.Type == t)?.Points ?? 0, + Coordinates = d.FirstOrDefault(x => x.Type == t)?.Coordinates, + }; + + return new Game + { + Id = Guid.NewGuid(), + DatePlayed = DateTime.Now, + Letter = row.Letter, + IsFinished = true, + City = Loc(row.Details, LocationType.City), + Village = Loc(row.Details, LocationType.Village), + Country = Loc(row.Details, LocationType.Country), + River = Loc(row.Details, LocationType.River), + Mountain = Loc(row.Details, LocationType.Mountain), + }; + } + + private static List MergeStoryPlaces(List existing, List newPlaces) + { + var merged = new List(existing); + foreach (var place in newPlaces) + { + if (!merged.Any(p => + p.Name.Equals(place.Name, StringComparison.OrdinalIgnoreCase) && p.Type == place.Type)) + { + merged.Add(place); + } + } + + return merged; + } + + public async ValueTask DisposeAsync() + { + if (_hub is not null) + await _hub.DisposeAsync(); + } +} diff --git a/src/FastGeography.Client/Pages/MultiplayerLobby.razor b/src/FastGeography.Client/Pages/MultiplayerLobby.razor new file mode 100644 index 0000000..8cf1b1c --- /dev/null +++ b/src/FastGeography.Client/Pages/MultiplayerLobby.razor @@ -0,0 +1,121 @@ +@page "/multiplayer" +@attribute [Authorize] +@implements IDisposable +@inject HttpClient Http +@inject NavigationManager Nav +@inject ActiveMultiplayerRoomState ActiveRoom +@inject IStringLocalizer L + +@L["Mp_PageTitle"] + +
+

@L["Mp_Heading"]

+

@L["Mp_Description"]

+ + @if (_returnRoomCode is not null) + { +
+
+ @string.Format(L["Mp_ReturnToRoom"]!, _returnRoomCode) +
@string.Format(L["Mp_ReturnToRoomDesc"]!, _returnRoomCode)
+
+ +
+ } + + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + +
+
+
@L["Mp_CreateRoomTitle"]
+

@L["Mp_CreateRoomDesc"]

+
+ + +
+ +
+
+ +
+
+
@L["Mp_JoinRoomTitle"]
+
+ + +
+
+
+
+ +@code { + private string _joinCode = string.Empty; + private string _createLang = "en"; + private bool _busy; + private string? _error; + private string? _returnRoomCode; + + protected override async Task OnInitializedAsync() + { + await ActiveRoom.EnsureLoadedAsync(); + _returnRoomCode = ActiveRoom.RoomCode; + ActiveRoom.Changed += OnActiveRoomChanged; + } + + private void OnActiveRoomChanged() + { + _returnRoomCode = ActiveRoom.RoomCode; + InvokeAsync(StateHasChanged); + } + + private async Task DismissReturnRoom() + { + await ActiveRoom.ClearAsync(); + _returnRoomCode = null; + } + + public void Dispose() => ActiveRoom.Changed -= OnActiveRoomChanged; + + private async Task CreateRoom() + { + _busy = true; + _error = null; + var resp = await Http.PostAsync($"api/rooms?lang={_createLang}", null); + _busy = false; + + if (resp.IsSuccessStatusCode) + { + var result = await resp.Content.ReadFromJsonAsync(); + if (result is not null) + Nav.NavigateTo($"/multiplayer/{result.RoomCode}"); + } + else + { + _error = L["Mp_CreateError"]; + } + } + + private void JoinRoom() + { + if (string.IsNullOrWhiteSpace(_joinCode)) { _error = L["Mp_JoinCodeEmpty"]; return; } + Nav.NavigateTo($"/multiplayer/{_joinCode.Trim().ToUpperInvariant()}"); + } +} diff --git a/src/FastGeography.Client/Pages/RankedGame.razor b/src/FastGeography.Client/Pages/RankedGame.razor new file mode 100644 index 0000000..4e99889 --- /dev/null +++ b/src/FastGeography.Client/Pages/RankedGame.razor @@ -0,0 +1,205 @@ +@page "/ranked" +@attribute [Authorize] +@inject HttpClient Http +@inject CookieAuthenticationStateProvider Auth +@inject ILogger Logger +@inject GameLanguageState Language +@inject IStringLocalizer L + +@L["Ranked_PageTitle"] + +
+
+
+

@L["Ranked_Heading"]

+ @L["Ranked_Subtitle"] +
+ + + @if (_checking) + { +
+ + @L["Ranked_Submitting"] +
+ } +
+ + @if (!string.IsNullOrEmpty(_error)) + { +
+ @_error + +
+ } + + @if (_result is not null) + { +
+ @L["Ranked_RoundComplete"] @L["Ranked_YouScored"] @_result.TotalPoints @L["Ranked_Points"]. + @L["Ranked_Badge"] @L[$"Badge_{_result.Badge}"] + @L["Ranked_ViewScoreboard"] +
+ + + + + @foreach (var d in _result.Details) + { + var location = ToGameLocation(d); + + + + + + } + +
@L["Col_Category"]@L["Col_Answer"]@L["Col_Points"]
@L[$"Location_{d.Type}"] + @(string.IsNullOrEmpty(d.Answer) ? "–" : d.Answer) + @if (!string.IsNullOrEmpty(d.Answer)) + { + + } + @d.Points
+
+ } + + @if (_roundActive && _roundId.HasValue) + { + + + + + + + + + + + + + + + + + + + + + + + +
@L["Col_Letter"]@L["Col_City"]@L["Col_Village"]@L["Col_Country"]@L["Col_River"]@L["Col_Mountain"]
@_letter + +
+ } +
+ +@code { + private Countdown _countdown = null!; + private Guid? _roundId; + private char _letter; + private string _languageCode = "en"; + private DateTime _endsAt; + private bool _roundActive; + private bool _checking; + private string? _error; + private SoloSubmitResponse? _result; + + private string? _city, _village, _country, _river, _mountain; + private List _storyPlaces = []; + + private async Task StartRound() + { + _error = null; + _result = null; + _storyPlaces = []; + + await Language.EnsureLoadedAsync(); + _languageCode = Language.Code; + + var response = await Http.PostAsync($"api/games/solo/start?lang={_languageCode}", null); + if (!response.IsSuccessStatusCode) + { + _error = L["Ranked_FailedToStart"]; + return; + } + + var start = await response.Content.ReadFromJsonAsync(); + if (start is null) { _error = L["Ranked_InvalidResponse"]; return; } + + _roundId = start.RoundId; + _letter = start.Letter; + _languageCode = start.LanguageCode; + _endsAt = start.EndsAt; + _city = _village = _country = _river = _mountain = string.Empty; + _roundActive = true; + + var secondsLeft = Math.Max(1, (int)(_endsAt - DateTime.UtcNow).TotalSeconds); + _countdown.Start(secondsLeft); + } + + private async Task OnTimerExpired() => await Submit(); + + private async Task Submit() + { + if (!_roundActive || !_roundId.HasValue) return; + + _countdown.Stop(); + _roundActive = false; + _checking = true; + _error = null; + + try + { + var req = new SubmitAnswersRequest(_city, _village, _country, _river, _mountain, _languageCode); + var resp = await Http.PostAsJsonAsync($"api/games/solo/{_roundId}/submit", req); + + if (resp.IsSuccessStatusCode) + { + _result = await resp.Content.ReadFromJsonAsync(); + _storyPlaces = _result?.Details + .Where(d => d.Points == ScoringRules.ValidPoints && !string.IsNullOrWhiteSpace(d.Answer)) + .Select(d => new StoryRequest(d.Answer!, d.Type, d.Coordinates, _languageCode)) + .ToList() ?? []; + } + else if (resp.StatusCode == System.Net.HttpStatusCode.Conflict) + { + _error = L["Ranked_AlreadySubmitted"]; + } + else if (resp.StatusCode == System.Net.HttpStatusCode.BadRequest) + { + _error = L["Ranked_RejectedSubmission"]; + } + else + { + _error = L["Ranked_SubmissionFailed"]; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error submitting ranked answers"); + _error = L["Ranked_SubmissionError"]; + } + finally + { + _checking = false; + } + } + + private static GameLocation ToGameLocation(LocationResult detail) => new() + { + LocationType = detail.Type, + Answer = detail.Answer, + Points = detail.Points, + Coordinates = detail.Coordinates + }; +} diff --git a/src/FastGeography.Client/Pages/Register.razor b/src/FastGeography.Client/Pages/Register.razor new file mode 100644 index 0000000..6ee28b1 --- /dev/null +++ b/src/FastGeography.Client/Pages/Register.razor @@ -0,0 +1,82 @@ +@page "/register" +@inject CookieAuthenticationStateProvider Auth +@inject NavigationManager Nav +@inject IStringLocalizer L + +@L["Reg_PageTitle"] + +
+
+

@L["Reg_Heading"]

+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + + + + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+ +

+ @L["Reg_HaveAccount"] @L["Reg_SignInLink"] +

+
+
+ +@code { + private readonly RegisterModel _model = new(); + private string? _error; + private bool _busy; + + private async Task DoRegister() + { + _busy = true; + _error = null; + var (ok, err) = await Auth.RegisterAsync(_model.Email, _model.Password, _model.DisplayName); + _busy = false; + + if (ok) Nav.NavigateTo("/"); + else _error = err ?? L["Reg_Failed"]; + } + + private sealed class RegisterModel + { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.StringLength(50, MinimumLength = 2)] + public string DisplayName { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Email { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.MinLength(6)] + public string Password { get; set; } = string.Empty; + } +} diff --git a/src/FastGeography.Client/Pages/Scoreboard.razor b/src/FastGeography.Client/Pages/Scoreboard.razor new file mode 100644 index 0000000..ef81eda --- /dev/null +++ b/src/FastGeography.Client/Pages/Scoreboard.razor @@ -0,0 +1,111 @@ +@page "/scoreboard" +@inject HttpClient Http +@inject IStringLocalizer L + +@L["Sb_PageTitle"] + +@if (_error is not null) +{ + +} + +
+

@L["Sb_Heading"]

+ +
+ + +
+ + + + + + + + @if (_loading) + { +
+
+ @L["Sb_Loading"] +
+ } + else if (_entries is null || !_entries.Any()) + { +

@L["Sb_NoScores"]

+ } + else + { +
+ + + + + + + + + + + + @foreach (var e in _entries) + { + + + + + + + + } + +
@L["Sb_ColRank"]@L["Sb_ColExplorer"]@L["Sb_ColBadge"]@L["Sb_ColPoints"]@L["Sb_ColGames"]
+ @if (e.Rank == 1) { 🥇 } + else if (e.Rank == 2) { 🥈 } + else if (e.Rank == 3) { 🥉 } + else { @e.Rank } + +
+ + @e.DisplayName +
+
@L[$"Badge_{e.Badge}"]@e.CareerPoints@e.GamesPlayed
+
+ } +
+ +@code { + private List? _entries; + private bool _loading = true; + private string _filter = "alltime"; + private string? _error; + + protected override async Task OnInitializedAsync() => await LoadLeaderboard("alltime"); + + private async Task LoadLeaderboard(string filter) + { + _filter = filter; + _loading = true; + _error = null; + _entries = null; + StateHasChanged(); + try + { + _entries = await Http.GetFromJsonAsync>($"api/leaderboard?filter={filter}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loading = false; + } + } +} diff --git a/src/FastGeography.Client/Pages/ScoreboardMe.razor b/src/FastGeography.Client/Pages/ScoreboardMe.razor new file mode 100644 index 0000000..6c57d84 --- /dev/null +++ b/src/FastGeography.Client/Pages/ScoreboardMe.razor @@ -0,0 +1,153 @@ +@page "/scoreboard/me" +@attribute [Authorize] +@inject HttpClient Http +@inject IStringLocalizer L + +@L["Sb_Me_PageTitle"] + +
+
+

@L["Sb_Me_Heading"]

+ @L["Sb_Me_BackToScoreboard"] +
+ + @if (_error is not null) + { + + } + else if (_loading) + { +
+
+ @L["Sb_Loading"] +
+ } + else if (_stats is not null) + { +
+
+
+
+
@L["Sb_ColExplorer"]
+
+ +
@_stats.DisplayName
+
+
+
+
+
+
+
+
@L["Sb_Me_GlobalRank"]
+
#@_stats.Rank
+
+
+
+
+
+
+
@L["Sb_ColPoints"]
+
@_stats.CareerPoints
+
+
+
+
+
+
+
@L["Sb_ColBadge"]
+
@L[$"Badge_{_stats.Badge}"]
+
+
+
+
+ +

+ @string.Format(L["Sb_Me_GamesSummary"]!, _stats.GamesPlayed) +

+ +
@L["Sb_Me_RecentRounds"]
+ + @if (_stats.RecentRounds.Count == 0) + { +

@L["Sb_Me_NoRounds"]

+ } + else + { +
+ + + + + + + + + + + @foreach (var round in _stats.RecentRounds) + { + + + + + + + } + +
@L["Sb_Me_ColPlayedAt"]@L["Sb_Me_ColMode"]@L["Col_Letter"]@L["Sb_ColPoints"]
@round.PlayedAt.ToLocalTime().ToString("g")@FormatMode(round.Mode)@round.Letter@round.Points
+
+ } + } +
+ +@code { + private PlayerStats? _stats; + private bool _loading = true; + private string? _error; + + protected override async Task OnInitializedAsync() => await LoadStatsAsync(); + + private async Task LoadStatsAsync() + { + _loading = true; + _error = null; + _stats = null; + StateHasChanged(); + + try + { + var response = await Http.GetAsync("api/leaderboard/me"); + if (response.IsSuccessStatusCode) + { + _stats = await response.Content.ReadFromJsonAsync(); + } + else if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _error = L["Sb_Me_ProfileNotFound"]; + } + else + { + _error = response.ReasonPhrase ?? response.StatusCode.ToString(); + } + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loading = false; + } + } + + private string FormatMode(string mode) => + mode.Equals("Multiplayer", StringComparison.OrdinalIgnoreCase) + ? L["Sb_Me_ModeMultiplayer"] + : L["Sb_Me_ModeRanked"]; + + private static string PointsClass(int points) => + points > 0 ? "text-success" : points < 0 ? "text-danger" : "text-muted"; +} diff --git a/src/FastGeography.Client/Program.cs b/src/FastGeography.Client/Program.cs index 51d78a6..dbb9e19 100644 --- a/src/FastGeography.Client/Program.cs +++ b/src/FastGeography.Client/Program.cs @@ -1,20 +1,48 @@ +using System.Globalization; + using BlazorApplicationInsights; using FastGeography.Client; +using FastGeography.Client.Auth; +using FastGeography.Client.Services; +using FastGeography.Shared; +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.JSInterop; var builder = WebAssemblyHostBuilder.CreateDefault(args); -// Add root components builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); -// Add services to the container -builder.Services.AddScoped(sp => - new HttpClient +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => +{ + var handler = sp.GetRequiredService(); + handler.InnerHandler = new HttpClientHandler(); + return new HttpClient(handler) { - BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) - }); + BaseAddress = new Uri(builder.HostEnvironment.BaseAddress), + Timeout = TimeSpan.FromMinutes(2) + }; +}); + +builder.Services.AddAuthorizationCore(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + sp.GetRequiredService()); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddLocalization(); builder.Services.AddBlazorApplicationInsights(); -await builder.Build().RunAsync(); +var host = builder.Build(); + +// Apply the stored language culture before the first render. +var js = host.Services.GetRequiredService(); +var stored = await js.InvokeAsync("localStorage.getItem", "fg_lang"); +var culture = new CultureInfo(GameLanguageExtensions.Parse(stored).ToCode()); +CultureInfo.DefaultThreadCurrentCulture = culture; +CultureInfo.DefaultThreadCurrentUICulture = culture; + +await host.RunAsync(); diff --git a/src/FastGeography.Client/Resources/UiStrings.cs b/src/FastGeography.Client/Resources/UiStrings.cs new file mode 100644 index 0000000..7953d2d --- /dev/null +++ b/src/FastGeography.Client/Resources/UiStrings.cs @@ -0,0 +1,7 @@ +namespace FastGeography.Client.Resources; + +/// +/// Marker class for IStringLocalizer<UiStrings> resource lookup. +/// The actual strings live in UiStrings.resx (English) and UiStrings.mk.resx (Macedonian). +/// +public sealed class UiStrings { } diff --git a/src/FastGeography.Client/Resources/UiStrings.mk.resx b/src/FastGeography.Client/Resources/UiStrings.mk.resx new file mode 100644 index 0000000..2f9a639 --- /dev/null +++ b/src/FastGeography.Client/Resources/UiStrings.mk.resx @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Слободна игра + Рангирана + Повеќеиграчи + Врати се во соба {0} + Табела + Најава + Регистрација + Одјава + Изберете јазик на играта + Навигациско мени + + + Започни ново патување! + Започни нова игра + Одбројување + Затвори + Се проверуваат одговорите… + Нема започната игра. Ве молам започнете. + Вкупно поени: {0} + Неверојатен истражувач! Ја освоивте значката {0}! + Одговори за географија + Некои одговори не можеа да се проверат. Обидете се повторно. + Одлично! Вие сте географски гениј! + Неверојатно! Продолжете со истражување! + Добра работа! Учите брзо! + Добар обид! Ајде да откриеме повеќе места! + Секоја игра ве прави попаметни! Обидете се повторно! + + + Буква + Град + Село + Држава + Река + Планина + Провери + Време (с) + Акција + Категорија + Одговор + Поени + Играч + + + Буква за игра + {0} кое почнува со {1} + Прикажи {0} на карта + Прикажи на карта + Провери + Проверено ({0}с) + Провери одговори за буквата {0} + Проверени одговори за буквата {0} за {1} секунди + + + Рангирана игра – FastGeography + Рангиран режим + Поените се зачувуваат на глобалната табела. + Започни рангирана рунда + Се испраќа на серверот… + Рундата е завршена! + Освоивте + поени + Значка: + Прикажи табела → + Провери + Не можеше да се започне рунда. Обидете се повторно. + Неважечки одговор од серверот. + Рундата е веќе испратена. + Одговорот е одбиен: можеби времето е истечено. + Испраќањето не успеа. Обидете се повторно. + Грешка при испраќање. Обидете се повторно. + + + Повеќеиграчи – FastGeography + Повеќеиграчи + Играјте живо против пријатели – сите добиваат иста буква и 60 секунди. + Создај нова соба + Добијте код за приклучување и споделете го со пријателите. + Јазик на играта + Англиски (латинска А–Ш) + Македонски (кирилица А–Ш) + Создај соба + Се создава… + Приклучи се на соба + Внесете код на собата + Приклучи + Не можеше да се создаде соба. Обидете се повторно. + Внесете код на собата. + Врати се во соба {0} + Ја напуштивте собата {0}. Приклучете се повторно за да продолжите со пријателите. + Отфрли + + + Соба: + Англиски + Македонски + Рунда {0} / {1} + Вкупно: {0} поени + Напушти соба + ИГРАЧИ + Започни рунда + Уште 5 рунди + Исклучени. + Поврзи повторно + Вашите одговори + Провери + Се чека другите играчи… + Серијата е завршена! + Освоивте {0} поени во {1} рунди. + Употребете „Уште 5 рунди" погоре за нова серија. + Резултати – рунда {0} + поени + Не може да се поврзе со серверот. + + + Табела – FastGeography + Глобална табела + Не можеше да се вчита табелата. + Сите времиња + Оваа недела + Мои статистики + Се вчитува… + Нема резултати. Бидете први да играте рангирана рунда! + # + Истражувач + Значка + Поени + Игри + Мои статистики – FastGeography + Мои статистики + Назад на табелата + Глобален ранг + {0} рангирани игри. + Неодамнешни рунди + Нема рангирани рунди. Играјте рангирана рунда за да ја видите историјата. + Играно + Режим + Рангирано + Мултиплеер + Профилот на играчот не е пронајден. + + + Најава – FastGeography + Најава + Е-пошта + Лозинка + Најава + Се најавувате… + Немате сметка? + Регистрирајте се тука + Погрешна е-пошта или лозинка. + + + Регистрација – FastGeography + Создај сметка + Прикажано име + Е-пошта + Лозинка + (6+ знаци) + Истражувачко име + Создај сметка + Се создава сметка… + Веќе имате сметка? + Најавете се + Регистрацијата не успеа. + + + Вчитување патни приказни… + Прочитај патничка приказна за {0} + Затвори приказна + Фотографија од {0} + {0}: {1} + преку {0} + + + Жал ни е, на оваа адреса нема ништо. + + + Град + Село + Држава + Река + Планина + + + Почетник + Кадет + Истражувач + Патник + Скокач + Земски серфер + Освојувач на Земјата + Сончев спектар + Галактички серфер + Галактички освојувач + diff --git a/src/FastGeography.Client/Resources/UiStrings.resx b/src/FastGeography.Client/Resources/UiStrings.resx new file mode 100644 index 0000000..99c1f4c --- /dev/null +++ b/src/FastGeography.Client/Resources/UiStrings.resx @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Casual Play + Ranked + Multiplayer + Return to room {0} + Scoreboard + Sign In + Register + Sign Out + Choose game language + Navigation menu + + + Start New Adventure! + Start a new game + Game countdown + Dismiss + Checking your answers… + No game started. Please start one. + Total points: {0} + Amazing Explorer! You earned the {0} badge! + Geography game answers + Some answers could not be checked. Please try again. + Outstanding! You are a Geography Genius! + Amazing job! Keep exploring! + Good work! You are learning fast! + Nice try! Let's discover more places! + Every game makes you smarter! Try again! + + + Letter + City + Village + Country + River + Mountain + Check + Time (s) + Action + Category + Answer + Points + Player + + + Game letter + {0} starting with {1} + View {0} on map + View on map + Check + Checked ({0}s) + Check answers for letter {0} + Checked answers for letter {0} in {1} seconds + + + Ranked Play – FastGeography + Ranked Mode + Points are saved to the global scoreboard. + Start Ranked Round + Submitting to server… + Round complete! + You scored + points + Badge: + View Scoreboard → + Check + Failed to start round. Please try again. + Invalid server response. + Round already submitted. + Submission rejected: time may have expired. + Submission failed. Please try again. + Submission error. Please try again. + + + Multiplayer – FastGeography + Multiplayer + Play live against friends – everyone gets the same letter and 60 seconds. + Create a New Room + Get a join code and share it with friends. + Game language + English (Latin A–Z) + Македонски / Macedonian (Cyrillic А–Ш) + Create Room + Creating… + Join a Room + Enter room code + Join + Could not create a room. Please try again. + Enter a room code. + Return to room {0} + You left room {0}. Rejoin to continue with your friends. + Dismiss + + + Room: + English + Македонски + Round {0} / {1} + Set total: {0} pts + Leave Room + PLAYERS + Start Round + Play Another 5 Rounds + Disconnected. + Reconnect + Your answers this set + Check + Waiting for other players… + Set complete! + You scored {0} points across {1} rounds. + Use Play Another 5 Rounds above to start a new set. + Round {0} Results + pts + Could not connect to the game server. + + + Scoreboard – FastGeography + Global Scoreboard + Could not load scoreboard. + All Time + This Week + My Stats + Loading… + No scores yet. Be the first to play a ranked round! + # + Explorer + Badge + Points + Games + My Stats – FastGeography + My Stats + Back to scoreboard + Global rank + {0} ranked games played. + Recent rounds + No ranked rounds yet. Play a ranked round to see your history here. + Played + Mode + Ranked + Multiplayer + Player profile not found. + + + Login – FastGeography + Sign In + Email + Password + Sign In + Signing in… + Don't have an account? + Register here + Invalid email or password. + + + Register – FastGeography + Create Account + Display Name + Email + Password + (6+ characters) + Explorer name + Create Account + Creating account… + Already have an account? + Sign in + Registration failed. + + + Loading travel stories… + Read travel story for {0} + Close story + Photo of {0} + {0}: {1} + via {0} + + + Sorry, there's nothing at this address. + + + City + Village + Country + River + Mountain + + + Junior + Cadet + Explorer + Traveller + Jumper + Earth Surfer + Earth Conqueror + Solar Spectre + Galactic Surfer + Galactic Conqueror + diff --git a/src/FastGeography.Client/Services/ActiveMultiplayerRoomState.cs b/src/FastGeography.Client/Services/ActiveMultiplayerRoomState.cs new file mode 100644 index 0000000..216bd34 --- /dev/null +++ b/src/FastGeography.Client/Services/ActiveMultiplayerRoomState.cs @@ -0,0 +1,82 @@ +namespace FastGeography.Client.Services; + +using System.Text.Json; + +using Microsoft.JSInterop; + +/// +/// Remembers the last multiplayer room the player joined so they can return +/// after accidental navigation away. Cleared on explicit leave or expiry. +/// +public sealed class ActiveMultiplayerRoomState +{ + private const string StorageKey = "fg_mp_room"; + private static readonly TimeSpan MaxAge = TimeSpan.FromHours(2); + + private readonly IJSRuntime _js; + private bool _loaded; + + public ActiveMultiplayerRoomState(IJSRuntime js) => _js = js; + + public string? RoomCode { get; private set; } + + public event Action? Changed; + + public async Task EnsureLoadedAsync() + { + if (_loaded) return; + + var json = await _js.InvokeAsync("localStorage.getItem", StorageKey); + if (!TryParse(json, out var code)) + { + RoomCode = null; + _loaded = true; + return; + } + + RoomCode = code; + _loaded = true; + } + + public async Task SetAsync(string roomCode) + { + var normalized = roomCode.Trim().ToUpperInvariant(); + if (string.IsNullOrEmpty(normalized)) return; + + var payload = JsonSerializer.Serialize(new StoredRoom(normalized, DateTime.UtcNow)); + await _js.InvokeVoidAsync("localStorage.setItem", StorageKey, payload); + RoomCode = normalized; + _loaded = true; + Changed?.Invoke(); + } + + public async Task ClearAsync() + { + await _js.InvokeVoidAsync("localStorage.removeItem", StorageKey); + RoomCode = null; + _loaded = true; + Changed?.Invoke(); + } + + private static bool TryParse(string? json, out string code) + { + code = string.Empty; + if (string.IsNullOrWhiteSpace(json)) return false; + + try + { + var stored = JsonSerializer.Deserialize(json); + if (stored is null || string.IsNullOrWhiteSpace(stored.Code)) return false; + if (DateTime.UtcNow - stored.JoinedAtUtc > MaxAge) return false; + + code = stored.Code.Trim().ToUpperInvariant(); + return code.Length > 0; + } + catch (JsonException) + { + return false; + } + } + + private sealed record StoredRoom(string Code, DateTime JoinedAtUtc); +} diff --git a/src/FastGeography.Client/Services/GameLanguageState.cs b/src/FastGeography.Client/Services/GameLanguageState.cs new file mode 100644 index 0000000..1fbb959 --- /dev/null +++ b/src/FastGeography.Client/Services/GameLanguageState.cs @@ -0,0 +1,54 @@ +namespace FastGeography.Client.Services; + +using System.Globalization; + +using FastGeography.Shared; + +using Microsoft.JSInterop; + +/// +/// Holds the current game language for the session. The nav picker writes here; +/// game pages read it when starting a round so a language change takes effect +/// immediately without a page reload. +/// Also sets so +/// returns +/// the correct language in all components. +/// +public sealed class GameLanguageState +{ + private readonly IJSRuntime _js; + private bool _loaded; + + public GameLanguageState(IJSRuntime js) => _js = js; + + public string Code { get; private set; } = "en"; + + public event Action? Changed; + + public async Task EnsureLoadedAsync() + { + if (_loaded) return; + + var stored = await _js.InvokeAsync("localStorage.getItem", "fg_lang"); + Code = GameLanguageExtensions.Parse(stored).ToCode(); + ApplyCulture(); + _loaded = true; + } + + public async Task SetAsync(string? code) + { + var parsed = GameLanguageExtensions.Parse(code).ToCode(); + Code = parsed; + _loaded = true; + ApplyCulture(); + await _js.InvokeVoidAsync("localStorage.setItem", "fg_lang", parsed); + Changed?.Invoke(); + } + + private void ApplyCulture() + { + var culture = new CultureInfo(Code); + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + } +} diff --git a/src/FastGeography.Client/Shared/AnswerActions.razor b/src/FastGeography.Client/Shared/AnswerActions.razor new file mode 100644 index 0000000..1d72804 --- /dev/null +++ b/src/FastGeography.Client/Shared/AnswerActions.razor @@ -0,0 +1,131 @@ +@inject IStringLocalizer L + +
+ @if (MapsUri is not null) + { + + @L[ + + } + + @if (_showStorySlot) + { + @if (_storyPending) + { + + + + } + else if (_story is not null) + { + @_stampAlt + +
+ + +
+ } + } + + @if (_pinned) + { + + } +
+ +@code { + [Parameter] public Uri? MapsUri { get; set; } + [Parameter] public string? Answer { get; set; } + [Parameter] public GameLocation? Location { get; set; } + [Parameter] public bool PopoverLeft { get; set; } + + [CascadingParameter] private DestinationStoriesContext? Stories { get; set; } + + private bool _pinned; + private DestinationStoryEntry? _story; + private bool _storyPending; + private bool _showStorySlot; + private bool _imageBroken; + private string _stampSrc = ""; + private string _stampAlt = ""; + private string _categoryIcon = ""; + private bool _showPopoverImage; + private bool _showFallbackArt; + + protected override void OnParametersSet() + { + _story = Stories?.GetStory(Location); + _storyPending = Stories?.IsStoryPending(Location) == true; + _showStorySlot = Stories?.ShouldShowStorySlot(Location) == true; + _imageBroken = false; + + _categoryIcon = Location is not null + ? PlaceCategoryIcon.CategoryIconPath(Location.LocationType) + : PlaceCategoryIcon.CategoryIconPath(LocationType.City); + + _stampAlt = Location is not null + ? string.Format(L["Stories_PlaceStamp"]!, L[$"Location_{Location.LocationType}"].Value, Answer) + : Answer ?? ""; + + if (_story is not null) + { + _showPopoverImage = !string.IsNullOrWhiteSpace(_story.ImageUrl); + _showFallbackArt = !_showPopoverImage; + _stampSrc = _showPopoverImage ? _story.ImageUrl! : _categoryIcon; + } + + if (!_showStorySlot || _story is null) + _pinned = false; + } + + private void TogglePin() => _pinned = !_pinned; + + private void ClosePin() => _pinned = false; + + private void OnImageError() + { + _imageBroken = true; + _showPopoverImage = false; + _showFallbackArt = true; + _stampSrc = _categoryIcon; + } + + private void OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + ClosePin(); + } +} diff --git a/src/FastGeography.Client/Shared/Countdown.razor b/src/FastGeography.Client/Shared/Countdown.razor index 4f952af..e9372b3 100644 --- a/src/FastGeography.Client/Shared/Countdown.razor +++ b/src/FastGeography.Client/Shared/Countdown.razor @@ -1,29 +1,30 @@ @using System.Timers; -
-
- @Time -
+
+
+ +
@code { private System.Timers.Timer _timer = null!; private int _secondsToRun = 0; - private int initialSeconds = 0; + private int _initialSeconds = 0; - protected string Time { get; set; } = "00:00"; + protected string Time { get; set; } = "01:00"; [Parameter] public EventCallback TimerOut { get; set; } - [Parameter] - public int SecondsToRun { get { return _secondsToRun; } set { } } - public int ElapsedSeconds { get { return initialSeconds - _secondsToRun; } } + public int ElapsedSeconds => _initialSeconds - _secondsToRun; public void Start(int secondsToRun) { - initialSeconds = secondsToRun; + _initialSeconds = secondsToRun; _secondsToRun = secondsToRun; if (_secondsToRun > 0) @@ -52,14 +53,14 @@ await InvokeAsync(() => { - Time = TimeSpan.FromSeconds(_secondsToRun).ToString(@"mm\:ss"); + Time = TimeSpan.FromSeconds(Math.Max(0, _secondsToRun)).ToString(@"mm\:ss"); StateHasChanged(); }); if (_secondsToRun <= 0) { _timer.Stop(); - await TimerOut.InvokeAsync(); + await InvokeAsync(() => TimerOut.InvokeAsync()); } } } diff --git a/src/FastGeography.Client/Shared/DestinationStories.razor b/src/FastGeography.Client/Shared/DestinationStories.razor new file mode 100644 index 0000000..6a76346 --- /dev/null +++ b/src/FastGeography.Client/Shared/DestinationStories.razor @@ -0,0 +1,122 @@ +@inject HttpClient Http +@inject GameLanguageState Language +@inject ILogger Logger +@implements IDisposable + + + @ChildContent + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + + [Parameter] + public List? Places { get; set; } + + private DestinationStoriesContext _context = new(null); + private int _loadId; + private readonly Dictionary<(string Name, LocationType Type), DestinationStoryEntry> _stories = new(); + + protected override async Task OnInitializedAsync() + { + await Language.EnsureLoadedAsync(); + Language.Changed += OnLanguageChanged; + } + + protected override async Task OnParametersSetAsync() => await LoadStoriesAsync(); + + private void OnLanguageChanged() + { + _stories.Clear(); + _ = InvokeAsync(LoadStoriesAsync); + } + + private async Task LoadStoriesAsync() + { + if (Places is null || Places.Count == 0) + { + _stories.Clear(); + _context = new DestinationStoriesContext(null); + return; + } + + await Language.EnsureLoadedAsync(); + var lang = Language.Code; + var places = Places.Select(p => p with { Lang = lang }).ToList(); + var pending = places + .Where(p => !_stories.ContainsKey(StoryKey(p))) + .ToList(); + + if (pending.Count == 0) + { + PublishContext(); + return; + } + + const int maxBatch = 10; + var loadId = ++_loadId; + var pendingKeys = pending.Select(p => (p.Name, p.Type)).ToList(); + PublishContext(pendingKeys); + await InvokeAsync(StateHasChanged); + + try + { + foreach (var batch in pending.Chunk(maxBatch)) + { + if (loadId != _loadId) + return; + + var body = new DestinationStoriesRequest(batch.ToList()); + var resp = await Http.PostAsJsonAsync("api/destination-stories", body); + if (loadId != _loadId) + return; + + if (!resp.IsSuccessStatusCode) + { + Logger.LogWarning( + "Destination stories request failed with {StatusCode}", + (int)resp.StatusCode); + continue; + } + + var data = await resp.Content.ReadFromJsonAsync(); + var results = data?.Stories ?? []; + foreach (var result in results) + { + _stories[StoryKey(result.Name, result.Type)] = new DestinationStoryEntry( + result.Story, + result.ImageUrl, + result.ImageAttribution); + } + } + } + catch (Exception ex) + { + if (loadId != _loadId) + return; + Logger.LogWarning(ex, "Could not load destination stories"); + } + + if (loadId != _loadId) + return; + + PublishContext(); + await InvokeAsync(StateHasChanged); + } + + private void PublishContext(IReadOnlyList<(string Name, LocationType Type)>? pendingKeys = null) + { + var stories = _stories + .Select(kv => (kv.Key.Name, kv.Key.Type, kv.Value.Story, kv.Value.ImageUrl, kv.Value.ImageAttribution)) + .ToList(); + _context = new DestinationStoriesContext(stories, pendingKeys); + } + + private static (string Name, LocationType Type) StoryKey(StoryRequest place) => + StoryKey(place.Name, place.Type); + + private static (string Name, LocationType Type) StoryKey(string name, LocationType type) => + (name.Trim().ToLowerInvariant(), type); + + public void Dispose() => Language.Changed -= OnLanguageChanged; +} diff --git a/src/FastGeography.Client/Shared/DestinationStoriesContext.cs b/src/FastGeography.Client/Shared/DestinationStoriesContext.cs new file mode 100644 index 0000000..8e05540 --- /dev/null +++ b/src/FastGeography.Client/Shared/DestinationStoriesContext.cs @@ -0,0 +1,72 @@ +namespace FastGeography.Client.Shared; + +using FastGeography.Shared; + +/// +/// A loaded destination story with optional place image. +/// +public sealed record DestinationStoryEntry( + string Story, + string? ImageUrl, + string? ImageAttribution); + +/// +/// Cascading context for destination story lookup inside a table. +/// +public sealed class DestinationStoriesContext +{ + private readonly Dictionary<(string NormalizedName, LocationType Type), DestinationStoryEntry> _stories; + private readonly HashSet<(string NormalizedName, LocationType Type)> _pendingKeys; + + public DestinationStoriesContext( + IReadOnlyList<(string Name, LocationType Type, string Story, string? ImageUrl, string? ImageAttribution)>? stories, + IReadOnlyList<(string Name, LocationType Type)>? pendingKeys = null) + { + _stories = new Dictionary<(string, LocationType), DestinationStoryEntry>(); + _pendingKeys = new HashSet<(string, LocationType)>(); + + if (stories is not null) + { + foreach (var (name, type, story, imageUrl, imageAttribution) in stories) + { + _stories[NormalizeKey(name, type)] = new DestinationStoryEntry( + story, imageUrl, imageAttribution); + } + } + + if (pendingKeys is not null) + { + foreach (var (name, type) in pendingKeys) + _pendingKeys.Add(NormalizeKey(name, type)); + } + } + + public bool IsEligible(GameLocation? location) => + location is not null + && location.Points == ScoringRules.ValidPoints + && !string.IsNullOrWhiteSpace(location.Answer); + + public bool IsStoryPending(GameLocation? location) + { + if (!IsEligible(location)) + return false; + + var key = NormalizeKey(location!.Answer!, location.LocationType); + return _pendingKeys.Contains(key) && !_stories.ContainsKey(key); + } + + public DestinationStoryEntry? GetStory(GameLocation? location) + { + if (!IsEligible(location)) + return null; + + var key = NormalizeKey(location!.Answer!, location.LocationType); + return _stories.TryGetValue(key, out var story) ? story : null; + } + + public bool ShouldShowStorySlot(GameLocation? location) => + IsStoryPending(location) || GetStory(location) is not null; + + private static (string NormalizedName, LocationType Type) NormalizeKey(string name, LocationType type) => + (name.Trim().ToLowerInvariant(), type); +} diff --git a/src/FastGeography.Client/Shared/GameRow.razor b/src/FastGeography.Client/Shared/GameRow.razor index 71d4ffd..54df5e2 100644 --- a/src/FastGeography.Client/Shared/GameRow.razor +++ b/src/FastGeography.Client/Shared/GameRow.razor @@ -1,54 +1,53 @@ -@using FastGeography.Shared; +@using FastGeography.Shared +@inject IStringLocalizer L - - - - @if (game.City.MapsUri != null) - { - - Go to location - - } + + - - - @if (game.Village.MapsUri != null) - { - - Go to location - - } + + + - - - @if (game.Country.MapsUri != null) - { - - Go to location - - } + + + - - - @if (game.River.MapsUri != null) - { - - Go to location - - } + + + - - - @if (game.Mountain.MapsUri != null) - { - - Go to location - - } + + + + + + + + + + - @game.SecondsPlayed - @code { @@ -62,29 +61,27 @@ [Parameter] public GameLocation? Mountain { get; set; } [Parameter] public GameLocation? River { get; set; } [Parameter] public bool IsFinished { get; set; } + [Parameter] public bool IsChecking { get; set; } [Parameter] public int SecondsPlayed { get; set; } - Game game = new Game(); + private Game game = new(); - async Task HandleClick() - { - await OnClick.InvokeAsync(game); - } + private async Task HandleClick() => await OnClick.InvokeAsync(game); protected override void OnParametersSet() { game = new Game - { - Id = Id, - DatePlayed = DatePlayed, - Letter = Letter, - City = City, - Village = Village, - Country = Country, - Mountain = Mountain, - River = River, - IsFinished = IsFinished, - SecondsPlayed = SecondsPlayed - }; + { + Id = Id, + DatePlayed = DatePlayed, + Letter = Letter, + City = City, + Village = Village, + Country = Country, + Mountain = Mountain, + River = River, + IsFinished = IsFinished, + SecondsPlayed = SecondsPlayed + }; } -} \ No newline at end of file +} diff --git a/src/FastGeography.Client/Shared/MainLayout.razor b/src/FastGeography.Client/Shared/MainLayout.razor index 3e7ef88..4b945fd 100644 --- a/src/FastGeography.Client/Shared/MainLayout.razor +++ b/src/FastGeography.Client/Shared/MainLayout.razor @@ -1,20 +1,25 @@ -@using FastGeography.Client.Pages -@inherits LayoutComponentBase +@inherits LayoutComponentBase +@implements IDisposable +@inject GameLanguageState Language
- @*
- @*
- About -
*@ - -
- @* @Body*@ - +
+ @Body
-
+ +@code { + protected override void OnInitialized() + { + Language.Changed += OnLanguageChanged; + } + + private void OnLanguageChanged() => InvokeAsync(StateHasChanged); + + public void Dispose() => Language.Changed -= OnLanguageChanged; +} diff --git a/src/FastGeography.Client/Shared/NavMenu.razor b/src/FastGeography.Client/Shared/NavMenu.razor index 093bf87..b57654b 100644 --- a/src/FastGeography.Client/Shared/NavMenu.razor +++ b/src/FastGeography.Client/Shared/NavMenu.razor @@ -1,7 +1,15 @@ -