feat: Initial commit for DorkNet Admin Mobile app

- Implemented main application structure using .NET MAUI.
- Added App class to manage navigation between Login and Admin pages.
- Created AppDesign class for consistent UI styling.
- Developed pages: LoginPage, DashboardPage, PlayersPage, RoomsPage, SettingsPage, and AdminTabbedPage.
- Integrated AdminApiClient for API interactions and SecureAdminSettings for secure storage of sensitive data.
- Configured Android and iOS platform-specific files including manifest and app delegate.
- Added README with build instructions and authentication details.
This commit is contained in:
Alexa
2026-06-09 13:47:37 +03:00
parent 2b87f3d7c5
commit cce20ebdb6
21 changed files with 1239 additions and 0 deletions
@@ -0,0 +1,23 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
namespace DorkNet.AdminMobile.Pages;
public sealed class AdminTabbedPage : TabbedPage
{
public AdminTabbedPage(
DashboardPage dashboard,
PlayersPage players,
RoomsPage rooms,
SettingsPage settings)
{
BarBackgroundColor = AppDesign.Canvas;
BarTextColor = AppDesign.Text;
SelectedTabColor = AppDesign.PlatformAccent;
UnselectedTabColor = AppDesign.Subtle;
Children.Add(dashboard);
Children.Add(players);
Children.Add(rooms);
Children.Add(settings);
}
}
@@ -0,0 +1,91 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class DashboardPage : ContentPage
{
private readonly AdminApiClient api;
private readonly Label status = AppDesign.StatusLabel();
private readonly Grid metrics = new() { ColumnSpacing = 10, RowSpacing = 10 };
public DashboardPage(AdminApiClient api)
{
this.api = api;
Title = "Dashboard";
Background = AppDesign.PageBackground;
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 14,
Children =
{
AppDesign.Title("Operations"),
refresh,
metrics,
status,
},
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (metrics.Children.Count == 0) await LoadAsync();
}
private async Task LoadAsync()
{
try
{
status.Text = "Loading...";
var s = await api.GetStatsAsync();
DrawMetrics(s);
status.Text = $"Server time {s.ServerTime:g}";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
}
private void DrawMetrics(AdminStats s)
{
metrics.Children.Clear();
metrics.RowDefinitions.Clear();
metrics.ColumnDefinitions.Clear();
metrics.ColumnDefinitions.Add(new ColumnDefinition());
metrics.ColumnDefinitions.Add(new ColumnDefinition());
AddMetric(0, 0, "Players", $"{s.Players.Total}", $"{s.Players.OnlineNow} online");
AddMetric(0, 1, "Rooms", $"{s.Rooms.Total}", $"{s.Rooms.ActiveSessionCount} active sessions");
AddMetric(1, 0, "Moderation", $"{s.Moderation.OpenReports}", "open reports");
AddMetric(1, 1, "Traffic", $"{s.Rooms.InGamePlayerCount}", $"{s.Rooms.TotalVisits:n0} visits");
AddMetric(2, 0, "New today", $"{s.Players.NewToday}", "players");
AddMetric(2, 1, "Photos", $"{s.Photos.Today}", "last 24h");
}
private void AddMetric(int row, int col, string label, string value, string caption)
{
while (metrics.RowDefinitions.Count <= row) metrics.RowDefinitions.Add(new RowDefinition());
var tile = AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 4,
Children =
{
new Label { Text = label, TextColor = AppDesign.Subtle, FontSize = 12 },
new Label { Text = value, TextColor = AppDesign.Text, FontSize = 28, FontAttributes = FontAttributes.Bold },
new Label { Text = caption, TextColor = AppDesign.Muted, FontSize = 12 },
},
}, new Thickness(14));
metrics.Add(tile, col, row);
}
}
+122
View File
@@ -0,0 +1,122 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class LoginPage : ContentPage
{
private readonly AdminApiClient api;
private readonly SecureAdminSettings settings;
private readonly Entry baseUrl = AppDesign.Entry("https://admin.example.com", Keyboard.Url);
private readonly Entry cfClientId = AppDesign.Entry("Cloudflare Access Client ID");
private readonly Entry cfClientSecret = AppDesign.Entry("Cloudflare Access Client Secret", secret: true);
private readonly Entry cfJwt = AppDesign.Entry("Cloudflare Access JWT assertion", secret: true);
private readonly Entry username = AppDesign.Entry("Admin username");
private readonly Entry password = AppDesign.Entry("Password", secret: true);
private readonly Label status = AppDesign.StatusLabel();
private readonly ActivityIndicator busy = new() { IsVisible = false };
private bool loaded;
public LoginPage(AdminApiClient api, SecureAdminSettings settings)
{
this.api = api;
this.settings = settings;
Title = "DorkNet Admin";
Background = AppDesign.PageBackground;
var login = AppDesign.PrimaryButton("Sign in");
login.Clicked += OnLogin;
var continueSaved = AppDesign.SecondaryButton("Continue saved session");
continueSaved.Clicked += async (_, _) =>
{
if (await settings.GetJwtAsync() is { Length: > 0 }) App.ShowAdmin();
else status.Text = "No saved DorkNet admin session is stored.";
};
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = new Thickness(20, 28),
Spacing = 14,
Children =
{
AppDesign.Title("Admin mobile"),
new Label
{
Text = "Connect to a DorkNet admin host. Cloudflare Access service tokens are sent with every request when configured.",
TextColor = AppDesign.Muted,
FontSize = 14,
},
AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 10,
Children =
{
AppDesign.Section("Connection"),
baseUrl,
cfClientId,
cfClientSecret,
cfJwt,
},
}),
AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 10,
Children =
{
AppDesign.Section("DorkNet admin login"),
username,
password,
login,
continueSaved,
},
}),
busy,
status,
},
},
};
}
protected override async void OnAppearing()
{
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;
}
private async void OnLogin(object? sender, EventArgs e)
{
busy.IsVisible = true;
busy.IsRunning = true;
status.Text = string.Empty;
try
{
await settings.SaveConnectionAsync(new AdminConnectionSettings(
baseUrl.Text ?? string.Empty,
cfClientId.Text ?? string.Empty,
cfClientSecret.Text ?? string.Empty,
cfJwt.Text ?? string.Empty));
await api.LoginAsync(username.Text ?? string.Empty, password.Text ?? string.Empty);
App.ShowAdmin();
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
busy.IsRunning = false;
busy.IsVisible = false;
}
}
}
+117
View File
@@ -0,0 +1,117 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class PlayersPage : ContentPage
{
private readonly AdminApiClient api;
private readonly Entry search = AppDesign.Entry("Search players");
private readonly CollectionView list = new();
private readonly Label status = AppDesign.StatusLabel();
private List<PlayerSummary> players = [];
public PlayersPage(AdminApiClient api)
{
this.api = api;
Title = "Players";
Background = AppDesign.PageBackground;
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
search.Completed += async (_, _) => await LoadAsync();
list.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 16 };
name.SetBinding(Label.TextProperty, nameof(PlayerSummary.DisplayName));
var handle = new Label { TextColor = AppDesign.Muted, FontSize = 13 };
handle.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Username), stringFormat: "@{0}"));
var detail = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
detail.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Level), stringFormat: "Level {0}"));
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, _) =>
{
if ((s as Button)?.BindingContext is PlayerSummary p)
await ConfirmAction($"Kick @{p.Username}?", () => api.KickPlayerAsync(p.Id, "Kicked from mobile admin."));
};
var resetAvatar = MiniButton("Reset avatar");
resetAvatar.Clicked += async (s, _) =>
{
if ((s as Button)?.BindingContext is PlayerSummary p)
await ConfirmAction($"Reset @{p.Username}'s avatar?", () => api.ResetAvatarAsync(p.Id));
};
var actions = new HorizontalStackLayout { Spacing = 8, Children = { kick, resetAvatar } };
return AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 6,
Children = { name, handle, detail, online, actions },
}, new Thickness(12, 10));
});
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 12,
Children =
{
AppDesign.Title("Players"),
new HorizontalStackLayout { Spacing = 8, Children = { search, refresh } },
list,
status,
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (players.Count == 0) await LoadAsync();
}
private async Task LoadAsync()
{
try
{
status.Text = "Loading...";
players = await api.GetPlayersAsync(search.Text);
list.ItemsSource = players;
status.Text = $"{players.Count} players loaded";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
}
private async Task ConfirmAction(string message, Func<Task> action)
{
if (!await DisplayAlert("Confirm", message, "Run", "Cancel")) return;
try
{
await action();
await LoadAsync();
}
catch (Exception ex)
{
await DisplayAlert("Request failed", ex.Message, "OK");
}
}
private static Button MiniButton(string text) => new()
{
Text = text,
FontSize = 12,
Padding = new Thickness(10, 6),
CornerRadius = 6,
BackgroundColor = AppDesign.SurfaceLifted,
TextColor = AppDesign.Text,
};
}
+97
View File
@@ -0,0 +1,97 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class RoomsPage : ContentPage
{
private readonly AdminApiClient api;
private readonly CollectionView rooms = new();
private readonly CollectionView instances = new();
private readonly Label status = AppDesign.StatusLabel();
private bool loaded;
public RoomsPage(AdminApiClient api)
{
this.api = api;
Title = "Rooms";
Background = AppDesign.PageBackground;
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
{
Children = { name, meta },
}, new Thickness(12, 9));
});
instances.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
name.SetBinding(Label.TextProperty, nameof(InstanceSummary.RoomName));
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
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
{
Children = { name, meta, count },
}, new Thickness(12, 9));
});
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
Content = new ScrollView
{
Content = new VerticalStackLayout
{
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,
},
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (!loaded)
{
loaded = true;
await LoadAsync();
}
}
private async Task LoadAsync()
{
try
{
status.Text = "Loading...";
var instanceRows = await api.GetInstancesAsync();
var roomRows = await api.GetRoomsAsync();
instances.ItemsSource = instanceRows;
rooms.ItemsSource = roomRows;
status.Text = $"{instanceRows.Count} active instances, {roomRows.Count} rooms loaded";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class SettingsPage : ContentPage
{
private readonly AdminApiClient api;
private readonly SecureAdminSettings settings;
private readonly Switch signupsDisabled = new();
private readonly Switch globalFriends = new();
private readonly Label status = AppDesign.StatusLabel();
private bool loading;
public SettingsPage(AdminApiClient api, SecureAdminSettings settings)
{
this.api = api;
this.settings = settings;
Title = "Settings";
Background = AppDesign.PageBackground;
signupsDisabled.Toggled += async (_, e) =>
{
if (!loading) await SaveToggleAsync(() => api.SetSignupsDisabledAsync(e.Value));
};
globalFriends.Toggled += async (_, e) =>
{
if (!loading) await SaveToggleAsync(() => api.SetGlobalFriendsAsync(e.Value));
};
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
var logout = AppDesign.DangerButton("Clear saved session");
logout.Clicked += (_, _) =>
{
settings.ClearSession();
App.ShowLogin();
};
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 14,
Children =
{
AppDesign.Title("Settings"),
refresh,
Row("Disable new account creation", signupsDisabled),
Row("Global friends mode", globalFriends),
logout,
status,
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadAsync();
}
private async Task LoadAsync()
{
try
{
loading = true;
status.Text = "Loading...";
var s = await api.GetSettingsAsync();
signupsDisabled.IsToggled = s.SignupsDisabled;
globalFriends.IsToggled = s.GlobalFriendsEnabled;
status.Text = $"Updated {s.UpdatedAt:g}";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
loading = false;
}
}
private async Task SaveToggleAsync(Func<Task> save)
{
try
{
status.Text = "Saving...";
await save();
status.Text = "Saved";
}
catch (Exception ex)
{
status.Text = ex.Message;
await LoadAsync();
}
}
private static Grid Row(string label, View control)
{
var grid = new Grid
{
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
},
Padding = new Thickness(12, 10),
};
grid.Add(new Label
{
Text = label,
TextColor = AppDesign.Text,
VerticalTextAlignment = TextAlignment.Center,
}, 0, 0);
grid.Add(control, 1, 0);
return AppDesign.GlassCard(grid, new Thickness(0));
}
}