From 4ef463f664d2a6f9bab2de73db3443d09d73f474 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:39:23 +0300 Subject: [PATCH] Add splash screen SVG graphic for Admin Mobile application --- DorkNet.AdminMobile/Design/AppDesign.cs | 92 +++++----- .../DorkNet.AdminMobile.csproj | 16 +- DorkNet.AdminMobile/Pages/LoginPage.cs | 25 ++- DorkNet.AdminMobile/Pages/PlayersPage.cs | 167 ++++++++++++++---- DorkNet.AdminMobile/Pages/RoomsPage.cs | 70 ++++++-- DorkNet.AdminMobile/Pages/SettingsPage.cs | 2 +- .../Platforms/Android/AndroidManifest.xml | 6 + .../Platforms/Android/MainActivity.cs | 5 + .../Resources/AppIcon/appicon.svg | 71 ++++++++ .../Resources/AppIcon/appiconfg.svg | 71 ++++++++ .../Resources/Brand/dorknet.svg | 71 ++++++++ .../Resources/Splash/splash.svg | 71 ++++++++ .../Services/AdminApiClient.cs | 6 + .../Services/SecureAdminSettings.cs | 21 ++- .../API/Rooms/V2/RoomsController.cs | 66 ++++++- 15 files changed, 640 insertions(+), 120 deletions(-) create mode 100644 DorkNet.AdminMobile/Resources/AppIcon/appicon.svg create mode 100644 DorkNet.AdminMobile/Resources/AppIcon/appiconfg.svg create mode 100644 DorkNet.AdminMobile/Resources/Brand/dorknet.svg create mode 100644 DorkNet.AdminMobile/Resources/Splash/splash.svg diff --git a/DorkNet.AdminMobile/Design/AppDesign.cs b/DorkNet.AdminMobile/Design/AppDesign.cs index 21ae895..69fc8a7 100644 --- a/DorkNet.AdminMobile/Design/AppDesign.cs +++ b/DorkNet.AdminMobile/Design/AppDesign.cs @@ -5,28 +5,19 @@ namespace DorkNet.AdminMobile.Design; public static class AppDesign { - public static readonly Color Canvas = Color.FromArgb("#0b0f14"); - public static readonly Color Surface = Color.FromArgb("#1d222c"); - public static readonly Color SurfaceGlass = Color.FromArgb("#bb222833"); - public static readonly Color SurfaceLifted = Color.FromArgb("#293141"); - public static readonly Color Text = Color.FromArgb("#f6f8fb"); - public static readonly Color Muted = Color.FromArgb("#aab4c4"); - public static readonly Color Subtle = Color.FromArgb("#748094"); - public static readonly Color Accent = Color.FromArgb("#78c7ff"); - public static readonly Color AndroidAccent = Color.FromArgb("#9ee37d"); - public static readonly Color Danger = Color.FromArgb("#f26d7d"); + public static readonly Color Canvas = Color.FromArgb("#0f1115"); + public static readonly Color Surface = Color.FromArgb("#171a20"); + public static readonly Color SurfaceLifted = Color.FromArgb("#20252d"); + public static readonly Color RowSurface = Color.FromArgb("#14171d"); + public static readonly Color Stroke = Color.FromArgb("#2a3039"); + public static readonly Color Text = Color.FromArgb("#f4f6fa"); + public static readonly Color Muted = Color.FromArgb("#aeb7c6"); + public static readonly Color Subtle = Color.FromArgb("#778294"); + public static readonly Color Accent = Color.FromArgb("#64b5ff"); + public static readonly Color AndroidAccent = Color.FromArgb("#b7dd72"); + public static readonly Color Danger = Color.FromArgb("#ff6f82"); - public static Brush PageBackground => new LinearGradientBrush - { - StartPoint = new Point(0, 0), - EndPoint = new Point(1, 1), - GradientStops = - { - new GradientStop(Color.FromArgb("#0b0f14"), 0), - new GradientStop(Color.FromArgb("#121927"), 0.52f), - new GradientStop(Color.FromArgb("#10151c"), 1), - }, - }; + public static Brush PageBackground => new SolidColorBrush(Canvas); public static Color PlatformAccent => #if ANDROID @@ -39,7 +30,7 @@ public static class AppDesign { Text = text, TextColor = Text, - FontSize = 28, + FontSize = 22, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation, }; @@ -56,26 +47,10 @@ public static class AppDesign public static Border GlassCard(View content, Thickness? padding = null) => new() { Padding = padding ?? new Thickness(14), - Stroke = Color.FromArgb("#33ffffff"), + Stroke = Stroke, StrokeThickness = 1, - Background = new LinearGradientBrush - { - StartPoint = new Point(0, 0), - EndPoint = new Point(1, 1), - GradientStops = - { - new GradientStop(Color.FromArgb("#cc27303d"), 0), - new GradientStop(Color.FromArgb("#99202733"), 1), - }, - }, - StrokeShape = new RoundRectangle { CornerRadius = 24 }, - Shadow = new Shadow - { - Brush = Brush.Black, - Offset = new Point(0, 12), - Radius = 22, - Opacity = 0.25f, - }, + BackgroundColor = Surface, + StrokeShape = new RoundRectangle { CornerRadius = 10 }, Content = content, }; @@ -84,10 +59,10 @@ public static class AppDesign Text = text, BackgroundColor = PlatformAccent, TextColor = Colors.Black, - CornerRadius = 22, - HeightRequest = 48, + CornerRadius = 10, + HeightRequest = 44, FontAttributes = FontAttributes.Bold, - Padding = new Thickness(18, 0), + Padding = new Thickness(14, 0), }; public static Button SecondaryButton(string text) => new() @@ -95,9 +70,9 @@ public static class AppDesign Text = text, BackgroundColor = SurfaceLifted, TextColor = Text, - CornerRadius = 22, - HeightRequest = 46, - Padding = new Thickness(16, 0), + CornerRadius = 10, + HeightRequest = 42, + Padding = new Thickness(14, 0), }; public static Button DangerButton(string text) => new() @@ -105,9 +80,9 @@ public static class AppDesign Text = text, BackgroundColor = Color.FromArgb("#4c1d28"), TextColor = Color.FromArgb("#ffd5dc"), - CornerRadius = 22, - HeightRequest = 44, - Padding = new Thickness(14, 0), + CornerRadius = 10, + HeightRequest = 42, + Padding = new Thickness(12, 0), }; public static Entry Entry(string placeholder, Keyboard? keyboard = null, bool secret = false) => new() @@ -119,7 +94,22 @@ public static class AppDesign BackgroundColor = Color.FromArgb("#19202b"), ClearButtonVisibility = ClearButtonVisibility.WhileEditing, Keyboard = keyboard ?? Keyboard.Text, - HeightRequest = 48, + HeightRequest = 44, + }; + + public static Label Caption(string text) => new() + { + Text = text, + TextColor = Muted, + FontSize = 13, + LineBreakMode = LineBreakMode.WordWrap, + }; + + public static BoxView Divider() => new() + { + HeightRequest = 1, + Color = Stroke, + Opacity = 0.7, }; public static Label StatusLabel() => new() diff --git a/DorkNet.AdminMobile/DorkNet.AdminMobile.csproj b/DorkNet.AdminMobile/DorkNet.AdminMobile.csproj index 8839015..4bddb53 100644 --- a/DorkNet.AdminMobile/DorkNet.AdminMobile.csproj +++ b/DorkNet.AdminMobile/DorkNet.AdminMobile.csproj @@ -10,9 +10,23 @@ DorkNet Admin net.dorknet.adminmobile 1.0 - 1 + 3 36 + android-arm;android-arm64;android-x86;android-x64 + false + false + None 23.0 14.2 + + + + + + + + + + diff --git a/DorkNet.AdminMobile/Pages/LoginPage.cs b/DorkNet.AdminMobile/Pages/LoginPage.cs index 7c2a1ab..e31d058 100644 --- a/DorkNet.AdminMobile/Pages/LoginPage.cs +++ b/DorkNet.AdminMobile/Pages/LoginPage.cs @@ -43,6 +43,13 @@ public sealed class LoginPage : ContentPage Spacing = 14, Children = { + new Image + { + Source = "dorknet.svg", + WidthRequest = 96, + HeightRequest = 96, + HorizontalOptions = LayoutOptions.Start, + }, AppDesign.Title("Admin mobile"), new Label { @@ -86,11 +93,19 @@ public sealed class LoginPage : ContentPage base.OnAppearing(); if (loaded) return; loaded = true; - var saved = await settings.LoadConnectionAsync(); - baseUrl.Text = saved.BaseUrl; - cfClientId.Text = saved.CloudflareAccessClientId; - cfClientSecret.Text = saved.CloudflareAccessClientSecret; - cfJwt.Text = saved.CloudflareAccessJwt; + try + { + var saved = await settings.LoadConnectionAsync(); + baseUrl.Text = saved.BaseUrl; + cfClientId.Text = saved.CloudflareAccessClientId; + cfClientSecret.Text = saved.CloudflareAccessClientSecret; + cfJwt.Text = saved.CloudflareAccessJwt; + } + catch (Exception ex) + { + baseUrl.Text = "https://admin.rec.net"; + status.Text = $"Secure storage was reset: {ex.Message}"; + } } private async void OnLogin(object? sender, EventArgs e) diff --git a/DorkNet.AdminMobile/Pages/PlayersPage.cs b/DorkNet.AdminMobile/Pages/PlayersPage.cs index 343094f..4e22630 100644 --- a/DorkNet.AdminMobile/Pages/PlayersPage.cs +++ b/DorkNet.AdminMobile/Pages/PlayersPage.cs @@ -11,6 +11,7 @@ public sealed class PlayersPage : ContentPage private readonly Entry search = AppDesign.Entry("Search players"); private readonly CollectionView list = new(); private readonly Label status = AppDesign.StatusLabel(); + private readonly Button refresh = AppDesign.SecondaryButton("Refresh"); private List players = []; public PlayersPage(AdminApiClient api) @@ -19,10 +20,26 @@ public sealed class PlayersPage : ContentPage Title = "Players"; Background = AppDesign.PageBackground; - var refresh = AppDesign.SecondaryButton("Refresh"); refresh.Clicked += async (_, _) => await LoadAsync(); search.Completed += async (_, _) => await LoadAsync(); + list.SelectionMode = SelectionMode.Single; + list.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem; + list.SelectionChanged += async (_, e) => + { + if (e.CurrentSelection.FirstOrDefault() is PlayerSummary player) + { + list.SelectedItem = null; + await PlayerActionsAsync(player); + } + }; + list.EmptyView = new Label + { + Text = "No players loaded", + TextColor = AppDesign.Subtle, + HorizontalTextAlignment = TextAlignment.Center, + VerticalTextAlignment = TextAlignment.Center, + }; list.ItemTemplate = new DataTemplate(() => { var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 16 }; @@ -34,40 +51,75 @@ public sealed class PlayersPage : ContentPage var online = new Label { TextColor = AppDesign.AndroidAccent, FontSize = 12, FontAttributes = FontAttributes.Bold }; online.SetBinding(Label.IsVisibleProperty, nameof(PlayerSummary.Online)); online.Text = "ONLINE"; - - var kick = MiniButton("Kick"); - kick.Clicked += async (s, _) => + var chevron = new Label { - if ((s as Button)?.BindingContext is PlayerSummary p) - await ConfirmAction($"Kick @{p.Username}?", () => api.KickPlayerAsync(p.Id, "Kicked from mobile admin.")); + Text = ">", + TextColor = AppDesign.Subtle, + FontSize = 18, + VerticalTextAlignment = TextAlignment.Center, }; - var resetAvatar = MiniButton("Reset avatar"); - resetAvatar.Clicked += async (s, _) => + var row = new Grid { - if ((s as Button)?.BindingContext is PlayerSummary p) - await ConfirmAction($"Reset @{p.Username}'s avatar?", () => api.ResetAvatarAsync(p.Id)); + RowDefinitions = + { + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + }, + ColumnDefinitions = + { + new ColumnDefinition(), + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + Padding = new Thickness(12, 10), + RowSpacing = 2, + ColumnSpacing = 10, + BackgroundColor = AppDesign.RowSurface, }; + row.Add(name, 0, 0); + row.Add(online, 1, 0); + row.Add(handle, 0, 1); + row.Add(detail, 0, 2); + row.Add(chevron, 2, 0); + Grid.SetRowSpan(chevron, 3); - var actions = new HorizontalStackLayout { Spacing = 8, Children = { kick, resetAvatar } }; - return AppDesign.GlassCard(new VerticalStackLayout + return new VerticalStackLayout { - Spacing = 6, - Children = { name, handle, detail, online, actions }, - }, new Thickness(12, 10)); + Spacing = 0, + Children = { row, AppDesign.Divider() }, + }; }); - Content = new VerticalStackLayout + var searchRow = new Grid { - Padding = 16, - Spacing = 12, - Children = + ColumnSpacing = 8, + ColumnDefinitions = { - AppDesign.Title("Players"), - new HorizontalStackLayout { Spacing = 8, Children = { search, refresh } }, - list, - status, + new ColumnDefinition(), + new ColumnDefinition { Width = GridLength.Auto }, }, }; + searchRow.Add(search, 0, 0); + searchRow.Add(refresh, 1, 0); + + var grid = new Grid + { + Padding = 16, + RowSpacing = 12, + RowDefinitions = + { + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition(), + new RowDefinition { Height = GridLength.Auto }, + }, + }; + grid.Add(AppDesign.Title("Players"), 0, 0); + grid.Add(searchRow, 0, 1); + grid.Add(list, 0, 2); + grid.Add(status, 0, 3); + Content = grid; } protected override async void OnAppearing() @@ -80,6 +132,7 @@ public sealed class PlayersPage : ContentPage { try { + refresh.IsEnabled = false; status.Text = "Loading..."; players = await api.GetPlayersAsync(search.Text); list.ItemsSource = players; @@ -89,6 +142,33 @@ public sealed class PlayersPage : ContentPage { status.Text = ex.Message; } + finally + { + refresh.IsEnabled = true; + } + } + + private async Task PlayerActionsAsync(PlayerSummary player) + { + var action = await DisplayActionSheet( + $"@{player.Username}", + "Cancel", + "Remove account", + "Kick", + "Reset avatar"); + + switch (action) + { + case "Kick": + await ConfirmAction($"Kick @{player.Username}?", () => api.KickPlayerAsync(player.Id, "Kicked from mobile admin.")); + break; + case "Reset avatar": + await ConfirmAction($"Reset @{player.Username}'s avatar?", () => api.ResetAvatarAsync(player.Id)); + break; + case "Remove account": + await RemoveAccountAsync(player); + break; + } } private async Task ConfirmAction(string message, Func action) @@ -105,13 +185,38 @@ public sealed class PlayersPage : ContentPage } } - private static Button MiniButton(string text) => new() + private async Task RemoveAccountAsync(PlayerSummary player) { - Text = text, - FontSize = 12, - Padding = new Thickness(10, 6), - CornerRadius = 6, - BackgroundColor = AppDesign.SurfaceLifted, - TextColor = AppDesign.Text, - }; + var username = await DisplayPromptAsync( + "Verify username", + $"Type {player.Username} exactly to remove @{player.Username}.", + "Next", + "Cancel", + maxLength: Math.Max(32, player.Username.Length), + keyboard: Keyboard.Text); + if (!string.Equals(username?.Trim(), player.Username, StringComparison.Ordinal)) return; + + var phrase = $"DELETE {player.Id}"; + var typedPhrase = await DisplayPromptAsync( + "Verify removal", + $"Type {phrase} exactly. This cannot be undone.", + "Remove", + "Cancel", + maxLength: phrase.Length, + keyboard: Keyboard.Text); + if (!string.Equals(typedPhrase?.Trim(), phrase, StringComparison.Ordinal)) return; + + if (!await DisplayAlert("Remove account", $"Permanently remove @{player.Username}?", "Remove", "Cancel")) return; + + try + { + status.Text = $"Removing @{player.Username}..."; + await api.DeletePlayerAsync(player.Id, player.Username, phrase, "Removed from mobile admin."); + await LoadAsync(); + } + catch (Exception ex) + { + await DisplayAlert("Removal failed", ex.Message, "OK"); + } + } } diff --git a/DorkNet.AdminMobile/Pages/RoomsPage.cs b/DorkNet.AdminMobile/Pages/RoomsPage.cs index 26588e1..54cf326 100644 --- a/DorkNet.AdminMobile/Pages/RoomsPage.cs +++ b/DorkNet.AdminMobile/Pages/RoomsPage.cs @@ -11,6 +11,7 @@ public sealed class RoomsPage : ContentPage private readonly CollectionView rooms = new(); private readonly CollectionView instances = new(); private readonly Label status = AppDesign.StatusLabel(); + private readonly Button refresh = AppDesign.SecondaryButton("Refresh"); private bool loaded; public RoomsPage(AdminApiClient api) @@ -19,18 +20,28 @@ public sealed class RoomsPage : ContentPage Title = "Rooms"; Background = AppDesign.PageBackground; + rooms.SelectionMode = SelectionMode.None; + rooms.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem; + rooms.EmptyView = EmptyList("No recent rooms"); rooms.ItemTemplate = new DataTemplate(() => { var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 }; name.SetBinding(Label.TextProperty, nameof(RoomSummary.Name)); var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 }; meta.SetBinding(Label.TextProperty, new Binding(nameof(RoomSummary.Id), stringFormat: "Room #{0}")); - return AppDesign.GlassCard(new VerticalStackLayout + var row = new VerticalStackLayout { + Padding = new Thickness(12, 9), + Spacing = 2, + BackgroundColor = AppDesign.RowSurface, Children = { name, meta }, - }, new Thickness(12, 9)); + }; + return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } }; }); + instances.SelectionMode = SelectionMode.None; + instances.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem; + instances.EmptyView = EmptyList("No active instances"); instances.ItemTemplate = new DataTemplate(() => { var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 }; @@ -39,35 +50,51 @@ public sealed class RoomsPage : ContentPage meta.SetBinding(Label.TextProperty, new Binding(nameof(InstanceSummary.PhotonRegionId), stringFormat: "Region {0}")); var count = new Label { TextColor = AppDesign.Subtle, FontSize = 12 }; count.SetBinding(Label.TextProperty, new Binding("Participants.Count", stringFormat: "{0} participants")); - return AppDesign.GlassCard(new VerticalStackLayout + var row = new VerticalStackLayout { + Padding = new Thickness(12, 9), + Spacing = 2, + BackgroundColor = AppDesign.RowSurface, Children = { name, meta, count }, - }, new Thickness(12, 9)); + }; + return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } }; }); - var refresh = AppDesign.SecondaryButton("Refresh"); refresh.Clicked += async (_, _) => await LoadAsync(); - Content = new ScrollView + var grid = new Grid { - Content = new VerticalStackLayout + Padding = 16, + RowSpacing = 10, + RowDefinitions = { - Padding = 16, - Spacing = 14, - Children = - { - AppDesign.Title("Rooms"), - refresh, - new Label { Text = "Live instances", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, - instances, - new Label { Text = "Recent rooms", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, - rooms, - status, - }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, + new RowDefinition { Height = GridLength.Auto }, + new RowDefinition { Height = new GridLength(2, GridUnitType.Star) }, + new RowDefinition { Height = GridLength.Auto }, }, }; + grid.Add(AppDesign.Title("Rooms"), 0, 0); + grid.Add(refresh, 0, 1); + grid.Add(new Label { Text = "Live instances", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 2); + grid.Add(instances, 0, 3); + grid.Add(new Label { Text = "Recent rooms", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 4); + grid.Add(rooms, 0, 5); + grid.Add(status, 0, 6); + Content = grid; } + private static Label EmptyList(string text) => new() + { + Text = text, + TextColor = AppDesign.Subtle, + HorizontalTextAlignment = TextAlignment.Center, + VerticalTextAlignment = TextAlignment.Center, + }; + protected override async void OnAppearing() { base.OnAppearing(); @@ -82,6 +109,7 @@ public sealed class RoomsPage : ContentPage { try { + refresh.IsEnabled = false; status.Text = "Loading..."; var instanceRows = await api.GetInstancesAsync(); var roomRows = await api.GetRoomsAsync(); @@ -93,5 +121,9 @@ public sealed class RoomsPage : ContentPage { status.Text = ex.Message; } + finally + { + refresh.IsEnabled = true; + } } } diff --git a/DorkNet.AdminMobile/Pages/SettingsPage.cs b/DorkNet.AdminMobile/Pages/SettingsPage.cs index 025010d..702ac45 100644 --- a/DorkNet.AdminMobile/Pages/SettingsPage.cs +++ b/DorkNet.AdminMobile/Pages/SettingsPage.cs @@ -96,7 +96,7 @@ public sealed class SettingsPage : ContentPage } } - private static Grid Row(string label, View control) + private static View Row(string label, View control) { var grid = new Grid { diff --git a/DorkNet.AdminMobile/Platforms/Android/AndroidManifest.xml b/DorkNet.AdminMobile/Platforms/Android/AndroidManifest.xml index 3e846aa..6ba59a9 100644 --- a/DorkNet.AdminMobile/Platforms/Android/AndroidManifest.xml +++ b/DorkNet.AdminMobile/Platforms/Android/AndroidManifest.xml @@ -2,4 +2,10 @@ + diff --git a/DorkNet.AdminMobile/Platforms/Android/MainActivity.cs b/DorkNet.AdminMobile/Platforms/Android/MainActivity.cs index 041fc42..0215b01 100644 --- a/DorkNet.AdminMobile/Platforms/Android/MainActivity.cs +++ b/DorkNet.AdminMobile/Platforms/Android/MainActivity.cs @@ -5,7 +5,12 @@ using Microsoft.Maui; namespace DorkNet.AdminMobile; [Activity( + Label = "DorkNet Admin", + Icon = "@mipmap/appicon", + RoundIcon = "@mipmap/appicon_round", MainLauncher = true, + Exported = true, + Theme = "@style/Maui.SplashTheme", ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | diff --git a/DorkNet.AdminMobile/Resources/AppIcon/appicon.svg b/DorkNet.AdminMobile/Resources/AppIcon/appicon.svg new file mode 100644 index 0000000..2bf84c6 --- /dev/null +++ b/DorkNet.AdminMobile/Resources/AppIcon/appicon.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DorkNet.AdminMobile/Resources/AppIcon/appiconfg.svg b/DorkNet.AdminMobile/Resources/AppIcon/appiconfg.svg new file mode 100644 index 0000000..2bf84c6 --- /dev/null +++ b/DorkNet.AdminMobile/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DorkNet.AdminMobile/Resources/Brand/dorknet.svg b/DorkNet.AdminMobile/Resources/Brand/dorknet.svg new file mode 100644 index 0000000..2bf84c6 --- /dev/null +++ b/DorkNet.AdminMobile/Resources/Brand/dorknet.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DorkNet.AdminMobile/Resources/Splash/splash.svg b/DorkNet.AdminMobile/Resources/Splash/splash.svg new file mode 100644 index 0000000..2bf84c6 --- /dev/null +++ b/DorkNet.AdminMobile/Resources/Splash/splash.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DorkNet.AdminMobile/Services/AdminApiClient.cs b/DorkNet.AdminMobile/Services/AdminApiClient.cs index c299ba1..296376d 100644 --- a/DorkNet.AdminMobile/Services/AdminApiClient.cs +++ b/DorkNet.AdminMobile/Services/AdminApiClient.cs @@ -61,6 +61,12 @@ public sealed class AdminApiClient(SecureAdminSettings settings) public Task ResetAvatarAsync(long playerId) => SendAsync(HttpMethod.Post, $"players/{playerId}/avatar/reset"); + public Task DeletePlayerAsync(long playerId, string username, string phrase, string? reason) => + SendAsync( + HttpMethod.Delete, + $"players/{playerId}", + new { ConfirmUsername = username, ConfirmPhrase = phrase, Reason = reason }); + private async Task SendAsync( HttpMethod method, string path, diff --git a/DorkNet.AdminMobile/Services/SecureAdminSettings.cs b/DorkNet.AdminMobile/Services/SecureAdminSettings.cs index bb09ce6..f663e3f 100644 --- a/DorkNet.AdminMobile/Services/SecureAdminSettings.cs +++ b/DorkNet.AdminMobile/Services/SecureAdminSettings.cs @@ -19,11 +19,22 @@ public sealed class SecureAdminSettings public async Task LoadConnectionAsync() { - var baseUrl = await SecureStorage.GetAsync(BaseUrlKey) ?? "https://admin.rec.net"; - var cfId = await SecureStorage.GetAsync(CfClientIdKey) ?? string.Empty; - var cfSecret = await SecureStorage.GetAsync(CfClientSecretKey) ?? string.Empty; - var cfJwt = await SecureStorage.GetAsync(CfJwtKey) ?? string.Empty; - return new AdminConnectionSettings(baseUrl, cfId, cfSecret, cfJwt); + try + { + var baseUrl = await SecureStorage.GetAsync(BaseUrlKey) ?? "https://admin.rec.net"; + var cfId = await SecureStorage.GetAsync(CfClientIdKey) ?? string.Empty; + var cfSecret = await SecureStorage.GetAsync(CfClientSecretKey) ?? string.Empty; + var cfJwt = await SecureStorage.GetAsync(CfJwtKey) ?? string.Empty; + return new AdminConnectionSettings(baseUrl, cfId, cfSecret, cfJwt); + } + catch + { + SecureStorage.Remove(BaseUrlKey); + SecureStorage.Remove(CfClientIdKey); + SecureStorage.Remove(CfClientSecretKey); + SecureStorage.Remove(CfJwtKey); + return new AdminConnectionSettings("https://admin.rec.net", string.Empty, string.Empty, string.Empty); + } } public async Task SaveConnectionAsync(AdminConnectionSettings settings) diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 7243e9a..32d5a01 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -2415,17 +2415,69 @@ public class RoomsController( return Ok(SceneWire(moving)); } - /// POST rooms/{id}/subrooms/{sub}/data — bare-path - /// equivalent of api/rooms/v4/saveData. Delegates to the - /// existing save logic so the watch's two URL variants land on the - /// same persistence path. + /// POST roomserver/rooms/{id}/subrooms/{sub}/data — + /// commit the blob returned by storage/upload as the current + /// scene save. The 2020 watch posts this as form-urlencoded + /// (filename, inventionUsage, savedByAccountId), + /// while some tooling uses JSON. Both variants delegate to the + /// existing saveData path so room, scene, dorm-state, presence, and + /// subscription fanout stay consistent. + [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] [Authorize] - public Task SubRoomData(long roomId, long subRoomId, - [FromBody] SaveRoomSceneRequest body) + public async Task SubRoomData(long roomId, long subRoomId) { + var body = await ReadSaveRoomSceneRequestAsync(); body.RoomSceneId = subRoomId; - return SaveDataCore(body, roomId); + return await SaveDataCore(body, roomId); + } + + private async Task ReadSaveRoomSceneRequestAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + var request = new SaveRoomSceneRequest + { + Filename = FirstFormValue(form, "filename", "Filename"), + RoomDataFilename = FirstFormValue(form, "roomDataFilename", "RoomDataFilename", "roomDataFileName", "RoomDataFileName"), + InventionUsageBase64 = FirstFormValue(form, "inventionUsage", "InventionUsage", "inventionUsageBase64", "InventionUsageBase64"), + }; + + if (long.TryParse(FirstFormValue(form, "requestPlayerId", "RequestPlayerId", "savedByAccountId", "SavedByAccountId"), out var requestPlayerId)) + request.RequestPlayerId = requestPlayerId; + if (int.TryParse(FirstFormValue(form, "saveRequestPlayerId", "SaveRequestPlayerId"), out var saveRequestPlayerId)) + request.SaveRequestPlayerId = saveRequestPlayerId; + + return request; + } + + if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + return await JsonSerializer.DeserializeAsync( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new SaveRoomSceneRequest(); + } + catch (JsonException) + { + return new SaveRoomSceneRequest(); + } + } + + return new SaveRoomSceneRequest(); + } + + private static string? FirstFormValue(IFormCollection form, params string[] keys) + { + foreach (var key in keys) + { + var value = form[key].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + return null; } private async Task MutateScene(long roomId, long subRoomId, Action mutator)