mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2025-05-11 10:26:57 +02:00
Use a 3-way theme switcher instead of a 2-way switcher (#1233)
This commit is contained in:
parent
9e7ad4d85c
commit
7a69c87b56
12 changed files with 106 additions and 116 deletions
|
@ -5,7 +5,7 @@
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Polly" Version="8.3.1" />
|
<PackageReference Include="Polly" Version="8.4.0" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.6.0" />
|
<PackageReference Include="RazorBlade" Version="0.6.0" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
||||||
|
|
|
@ -7,13 +7,18 @@
|
||||||
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
|
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
|
||||||
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles">
|
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
||||||
|
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
<framework:ViewManager />
|
<framework:ViewManager />
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<materialStyles:MaterialTheme />
|
<!-- This theme is used as a stub to pre-load default resources, the actual colors are set through code -->
|
||||||
|
<materialStyles:MaterialTheme
|
||||||
|
BaseTheme="Light"
|
||||||
|
PrimaryColor="Grey"
|
||||||
|
SecondaryColor="DeepOrange" />
|
||||||
<materialIcons:MaterialIconStyles />
|
<materialIcons:MaterialIconStyles />
|
||||||
<dialogHostAvalonia:DialogHostStyles />
|
<dialogHostAvalonia:DialogHostStyles />
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,8 @@ using Avalonia.Media;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
|
using DiscordChatExporter.Gui.Utils;
|
||||||
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
using DiscordChatExporter.Gui.ViewModels;
|
using DiscordChatExporter.Gui.ViewModels;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
@ -16,11 +18,14 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui;
|
namespace DiscordChatExporter.Gui;
|
||||||
|
|
||||||
public partial class App : Application, IDisposable
|
public class App : Application, IDisposable
|
||||||
{
|
{
|
||||||
private readonly ServiceProvider _services;
|
private readonly ServiceProvider _services;
|
||||||
|
private readonly SettingsService _settingsService;
|
||||||
private readonly MainViewModel _mainViewModel;
|
private readonly MainViewModel _mainViewModel;
|
||||||
|
|
||||||
|
private readonly DisposableCollector _eventRoot = new();
|
||||||
|
|
||||||
public App()
|
public App()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
@ -43,17 +48,62 @@ public partial class App : Application, IDisposable
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
|
|
||||||
_services = services.BuildServiceProvider(true);
|
_services = services.BuildServiceProvider(true);
|
||||||
|
_settingsService = _services.GetRequiredService<SettingsService>();
|
||||||
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
|
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
|
||||||
|
|
||||||
|
// Re-initialize the theme when the user changes it
|
||||||
|
_eventRoot.Add(
|
||||||
|
_settingsService.WatchProperty(
|
||||||
|
o => o.Theme,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
RequestedThemeVariant = _settingsService.Theme switch
|
||||||
|
{
|
||||||
|
ThemeVariant.System => Avalonia.Styling.ThemeVariant.Default,
|
||||||
|
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
|
||||||
|
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
|
||||||
|
_
|
||||||
|
=> throw new InvalidOperationException(
|
||||||
|
$"Unknown theme '{_settingsService.Theme}'."
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
InitializeTheme();
|
||||||
|
},
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
|
base.Initialize();
|
||||||
|
|
||||||
// Increase maximum concurrent connections
|
// Increase maximum concurrent connections
|
||||||
ServicePointManager.DefaultConnectionLimit = 20;
|
ServicePointManager.DefaultConnectionLimit = 20;
|
||||||
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InitializeTheme()
|
||||||
|
{
|
||||||
|
var actualTheme = RequestedThemeVariant?.Key switch
|
||||||
|
{
|
||||||
|
"Light" => PlatformThemeVariant.Light,
|
||||||
|
"Dark" => PlatformThemeVariant.Dark,
|
||||||
|
_ => PlatformSettings?.GetColorValues().ThemeVariant
|
||||||
|
};
|
||||||
|
|
||||||
|
this.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = actualTheme switch
|
||||||
|
{
|
||||||
|
PlatformThemeVariant.Light
|
||||||
|
=> Theme.Create(Theme.Light, Color.Parse("#343838"), Color.Parse("#F9A825")),
|
||||||
|
PlatformThemeVariant.Dark
|
||||||
|
=> Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825")),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown theme '{actualTheme}'.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
@ -61,50 +111,20 @@ public partial class App : Application, IDisposable
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
|
||||||
// Set custom theme colors
|
// Set up custom theme colors
|
||||||
SetDefaultTheme();
|
InitializeTheme();
|
||||||
|
|
||||||
|
// Load settings
|
||||||
|
_settingsService.Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _services.Dispose();
|
private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>
|
||||||
}
|
// Re-initialize the theme when the system theme changes
|
||||||
|
InitializeTheme();
|
||||||
|
|
||||||
public partial class App
|
public void Dispose()
|
||||||
{
|
|
||||||
public static void SetLightTheme()
|
|
||||||
{
|
{
|
||||||
if (Current is null)
|
_eventRoot.Dispose();
|
||||||
return;
|
_services.Dispose();
|
||||||
|
|
||||||
Current.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = Theme.Create(
|
|
||||||
Theme.Light,
|
|
||||||
Color.Parse("#343838"),
|
|
||||||
Color.Parse("#F9A825")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SetDarkTheme()
|
|
||||||
{
|
|
||||||
if (Current is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Current.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = Theme.Create(
|
|
||||||
Theme.Dark,
|
|
||||||
Color.Parse("#E8E8E8"),
|
|
||||||
Color.Parse("#F9A825")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void SetDefaultTheme()
|
|
||||||
{
|
|
||||||
if (Current is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var isDarkModeEnabledByDefault =
|
|
||||||
Current.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark;
|
|
||||||
|
|
||||||
if (isDarkModeEnabledByDefault)
|
|
||||||
SetDarkTheme();
|
|
||||||
else
|
|
||||||
SetLightTheme();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ public class LocaleToDisplayNameStringConverter : IValueConverter
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
value is string locale && !string.IsNullOrWhiteSpace(locale)
|
value is string locale && !string.IsNullOrWhiteSpace(locale)
|
||||||
? CultureInfo.GetCultureInfo(locale).DisplayName
|
? CultureInfo.GetCultureInfo(locale).DisplayName
|
||||||
: "System default";
|
: "System";
|
||||||
|
|
||||||
public object ConvertBack(
|
public object ConvertBack(
|
||||||
object? value,
|
object? value,
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
<PackageReference Include="DialogHost.Avalonia" Version="0.7.7" />
|
<PackageReference Include="DialogHost.Avalonia" Version="0.7.7" />
|
||||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.4" PrivateAssets="all" />
|
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.4" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="Material.Avalonia" Version="3.5.0" />
|
<PackageReference Include="Material.Avalonia" Version="3.6.0" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.1.9" />
|
<PackageReference Include="Material.Icons.Avalonia" Version="2.1.9" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||||
<PackageReference Include="Onova" Version="2.6.11" />
|
<PackageReference Include="Onova" Version="2.6.11" />
|
||||||
|
|
8
DiscordChatExporter.Gui/Framework/ThemeVariant.cs
Normal file
8
DiscordChatExporter.Gui/Framework/ThemeVariant.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace DiscordChatExporter.Gui.Framework;
|
||||||
|
|
||||||
|
public enum ThemeVariant
|
||||||
|
{
|
||||||
|
System,
|
||||||
|
Light,
|
||||||
|
Dark
|
||||||
|
}
|
|
@ -1,12 +1,10 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Platform;
|
|
||||||
using Cogwheel;
|
using Cogwheel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
|
using DiscordChatExporter.Gui.Framework;
|
||||||
using DiscordChatExporter.Gui.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
using Microsoft.Win32;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Services;
|
namespace DiscordChatExporter.Gui.Services;
|
||||||
|
|
||||||
|
@ -18,10 +16,10 @@ public partial class SettingsService()
|
||||||
private bool _isUkraineSupportMessageEnabled = true;
|
private bool _isUkraineSupportMessageEnabled = true;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isAutoUpdateEnabled = true;
|
private ThemeVariant _theme;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isDarkModeEnabled;
|
private bool _isAutoUpdateEnabled = true;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isTokenPersisted = true;
|
private bool _isTokenPersisted = true;
|
||||||
|
@ -62,17 +60,6 @@ public partial class SettingsService()
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _lastAssetsDirPath;
|
private string? _lastAssetsDirPath;
|
||||||
|
|
||||||
public override void Reset()
|
|
||||||
{
|
|
||||||
base.Reset();
|
|
||||||
|
|
||||||
// Reset the dark mode setting separately because its default value is evaluated dynamically
|
|
||||||
// and cannot be set by the field initializer.
|
|
||||||
IsDarkModeEnabled =
|
|
||||||
Application.Current?.PlatformSettings?.GetColorValues().ThemeVariant
|
|
||||||
== PlatformThemeVariant.Dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Save()
|
public override void Save()
|
||||||
{
|
{
|
||||||
// Clear the token if it's not supposed to be persisted
|
// Clear the token if it's not supposed to be persisted
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
using System;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
using DiscordChatExporter.Gui.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
|
@ -23,18 +22,20 @@ public class SettingsViewModel : DialogViewModelBase
|
||||||
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
|
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();
|
||||||
|
|
||||||
|
public ThemeVariant Theme
|
||||||
|
{
|
||||||
|
get => _settingsService.Theme;
|
||||||
|
set => _settingsService.Theme = value;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsAutoUpdateEnabled
|
public bool IsAutoUpdateEnabled
|
||||||
{
|
{
|
||||||
get => _settingsService.IsAutoUpdateEnabled;
|
get => _settingsService.IsAutoUpdateEnabled;
|
||||||
set => _settingsService.IsAutoUpdateEnabled = value;
|
set => _settingsService.IsAutoUpdateEnabled = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsDarkModeEnabled
|
|
||||||
{
|
|
||||||
get => _settingsService.IsDarkModeEnabled;
|
|
||||||
set => _settingsService.IsDarkModeEnabled = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsTokenPersisted
|
public bool IsTokenPersisted
|
||||||
{
|
{
|
||||||
get => _settingsService.IsTokenPersisted;
|
get => _settingsService.IsTokenPersisted;
|
||||||
|
|
|
@ -79,18 +79,6 @@ public partial class MainViewModel(
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task InitializeAsync()
|
private async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
// Reset settings (needed to resolve the default dark mode setting)
|
|
||||||
settingsService.Reset();
|
|
||||||
|
|
||||||
// Load settings
|
|
||||||
settingsService.Load();
|
|
||||||
|
|
||||||
// Set the correct theme
|
|
||||||
if (settingsService.IsDarkModeEnabled)
|
|
||||||
App.SetDarkTheme();
|
|
||||||
else
|
|
||||||
App.SetLightTheme();
|
|
||||||
|
|
||||||
await ShowUkraineSupportMessageAsync();
|
await ShowUkraineSupportMessageAsync();
|
||||||
await CheckForUpdatesAsync();
|
await CheckForUpdatesAsync();
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,19 @@
|
||||||
BorderThickness="0,1">
|
BorderThickness="0,1">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
|
<!-- Theme -->
|
||||||
|
<DockPanel
|
||||||
|
Margin="16,8"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip.Tip="Preferred user interface theme">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Theme" />
|
||||||
|
<ComboBox
|
||||||
|
Width="150"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
ItemsSource="{Binding AvailableThemes}"
|
||||||
|
SelectedItem="{Binding Theme}" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Auto-updates -->
|
<!-- Auto-updates -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
|
@ -35,19 +48,6 @@
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Dark mode -->
|
|
||||||
<DockPanel
|
|
||||||
Margin="16,8"
|
|
||||||
LastChildFill="False"
|
|
||||||
ToolTip.Tip="Use darker colors in the UI">
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Dark mode" />
|
|
||||||
<ToggleSwitch
|
|
||||||
x:Name="DarkModeToggleSwitch"
|
|
||||||
DockPanel.Dock="Right"
|
|
||||||
IsChecked="{Binding IsDarkModeEnabled}"
|
|
||||||
IsCheckedChanged="DarkModeToggleSwitch_OnIsCheckedChanged" />
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Persist token -->
|
<!-- Persist token -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
using System.Windows;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using DiscordChatExporter.Gui.Framework;
|
|
||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Views.Dialogs;
|
namespace DiscordChatExporter.Gui.Views.Dialogs;
|
||||||
|
@ -8,20 +6,4 @@ namespace DiscordChatExporter.Gui.Views.Dialogs;
|
||||||
public partial class SettingsView : UserControl<SettingsViewModel>
|
public partial class SettingsView : UserControl<SettingsViewModel>
|
||||||
{
|
{
|
||||||
public SettingsView() => InitializeComponent();
|
public SettingsView() => InitializeComponent();
|
||||||
|
|
||||||
private void DarkModeToggleSwitch_OnIsCheckedChanged(object? sender, RoutedEventArgs args)
|
|
||||||
{
|
|
||||||
if (DarkModeToggleSwitch.IsChecked is true)
|
|
||||||
{
|
|
||||||
App.SetDarkTheme();
|
|
||||||
}
|
|
||||||
else if (DarkModeToggleSwitch.IsChecked is false)
|
|
||||||
{
|
|
||||||
App.SetLightTheme();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
App.SetDefaultTheme();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue