From e00c26177751a8984f0ccb7c217926e60f377b93 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 1 Jul 2024 17:11:26 +0800 Subject: [PATCH 1/6] Fix #4358: RichTextEditor Provider Abstraction. --- Oqtane.Client/Modules/Admin/Site/Index.razor | 344 +++++++++++------- ...xtEditor.razor => QuillJSTextEditor.razor} | 95 +++-- .../Controls/QuillJSTextEditorSettings.razor | 99 +++++ .../Modules/Controls/RichTextEditor.razor | 104 +++--- .../Modules/Controls/TextAreaTextEditor.razor | 31 ++ Oqtane.Client/Modules/HtmlText/Edit.razor | 60 ++- Oqtane.Client/Modules/HtmlText/ModuleInfo.cs | 2 +- Oqtane.Client/Modules/HtmlText/Settings.razor | 61 ---- Oqtane.Client/Modules/ModuleBase.cs | 36 ++ .../Providers/QuillJSTextEditorProvider.cs | 13 + .../Providers/QuillTextEditorProvider.cs | 11 - .../Providers/TextAreaTextEditorProvider.cs | 13 + .../Resources/Modules/Admin/Site/Index.resx | 6 + ...TextEditor.resx => QuillJSTextEditor.resx} | 0 .../Controls/QuillJSTextEditorSettings.resx | 156 ++++++++ Oqtane.Server/Components/App.razor | 6 +- .../OqtaneServiceCollectionExtensions.cs | 3 +- Oqtane.Server/wwwroot/css/app.css | 6 + Oqtane.Shared/Interfaces/IModuleControl.cs | 13 + .../Interfaces/ITextEditorProvider.cs | 5 + Oqtane.Shared/Shared/Constants.cs | 4 +- 21 files changed, 726 insertions(+), 342 deletions(-) rename Oqtane.Client/Modules/Controls/{QuillTextEditor.razor => QuillJSTextEditor.razor} (83%) create mode 100644 Oqtane.Client/Modules/Controls/QuillJSTextEditorSettings.razor create mode 100644 Oqtane.Client/Modules/Controls/TextAreaTextEditor.razor delete mode 100644 Oqtane.Client/Modules/HtmlText/Settings.razor create mode 100644 Oqtane.Client/Providers/QuillJSTextEditorProvider.cs delete mode 100644 Oqtane.Client/Providers/QuillTextEditorProvider.cs create mode 100644 Oqtane.Client/Providers/TextAreaTextEditorProvider.cs rename Oqtane.Client/Resources/Modules/Controls/{QuillTextEditor.resx => QuillJSTextEditor.resx} (100%) create mode 100644 Oqtane.Client/Resources/Modules/Controls/QuillJSTextEditorSettings.resx diff --git a/Oqtane.Client/Modules/Admin/Site/Index.razor b/Oqtane.Client/Modules/Admin/Site/Index.razor index a5626a5d..5d813d27 100644 --- a/Oqtane.Client/Modules/Admin/Site/Index.razor +++ b/Oqtane.Client/Modules/Admin/Site/Index.razor @@ -1,6 +1,7 @@ @namespace Oqtane.Modules.Admin.Site @inherits ModuleBase @using System.Text.RegularExpressions +@using Microsoft.Extensions.DependencyInjection @inject NavigationManager NavigationManager @inject ISiteService SiteService @inject ITenantService TenantService @@ -8,6 +9,7 @@ @inject IAliasService AliasService @inject IThemeService ThemeService @inject ISettingService SettingService +@inject IServiceProvider ServiceProvider @inject IStringLocalizer Localizer @inject INotificationService NotificationService @inject IStringLocalizer SharedLocalizer @@ -123,6 +125,28 @@ +
+ +
+ +
+
+ @if (_textEditorProviderSettings != null) + { +
+
+ @_textEditorProviderSettings +
+
+ }
@@ -438,6 +462,10 @@ private string _tenant = string.Empty; private string _database = string.Empty; private string _connectionstring = string.Empty; + private string _textEditorProvider = ""; + private IEnumerable _textEditorProviders; + private RenderFragment _textEditorProviderSettings; + private ISettingsControl _textEditorProviderSettingsControl; private string _createdby; private DateTime _createdon; private string _modifiedby; @@ -479,6 +507,7 @@ _containers = ThemeService.GetContainerControls(PageState.Site.Themes, _themetype); _containertype = (!string.IsNullOrEmpty(site.DefaultContainerType)) ? site.DefaultContainerType : Constants.DefaultContainer; _admincontainertype = (!string.IsNullOrEmpty(site.AdminContainerType)) ? site.AdminContainerType : Constants.DefaultAdminContainer; + _textEditorProviders = ServiceProvider.GetServices(); // page content _headcontent = site.HeadContent; @@ -517,6 +546,10 @@ // aliases await GetAliases(); + //text editor + _textEditorProvider = SettingService.GetSetting(settings, "TextEditorProvider", Constants.DefaultTextEditorProvider); + LoadTextEditorProviderSettingsControl(); + // hosting model _rendermode = site.RenderMode; _runtime = site.Runtime; @@ -673,17 +706,17 @@ } } - site = await SiteService.UpdateSiteAsync(site); + site = await SiteService.UpdateSiteAsync(site); // SMTP - var settings = await SettingService.GetSiteSettingsAsync(site.SiteId); + var settings = await SettingService.GetSiteSettingsAsync(site.SiteId); settings = SettingService.SetSetting(settings, "SMTPHost", _smtphost, true); - settings = SettingService.SetSetting(settings, "SMTPPort", _smtpport, true); - settings = SettingService.SetSetting(settings, "SMTPSSL", _smtpssl, true); - settings = SettingService.SetSetting(settings, "SMTPUsername", _smtpusername, true); - settings = SettingService.SetSetting(settings, "SMTPPassword", _smtppassword, true); - settings = SettingService.SetSetting(settings, "SMTPSender", _smtpsender, true); - settings = SettingService.SetSetting(settings, "SMTPRelay", _smtprelay, true); + settings = SettingService.SetSetting(settings, "SMTPPort", _smtpport, true); + settings = SettingService.SetSetting(settings, "SMTPSSL", _smtpssl, true); + settings = SettingService.SetSetting(settings, "SMTPUsername", _smtpusername, true); + settings = SettingService.SetSetting(settings, "SMTPPassword", _smtppassword, true); + settings = SettingService.SetSetting(settings, "SMTPSender", _smtpsender, true); + settings = SettingService.SetSetting(settings, "SMTPRelay", _smtprelay, true); settings = SettingService.SetSetting(settings, "SMTPEnabled", _smtpenabled, true); settings = SettingService.SetSetting(settings, "SiteGuid", _siteguid, true); settings = SettingService.SetSetting(settings, "NotificationRetention", _retention.ToString(), true); @@ -692,141 +725,149 @@ settings = SettingService.SetSetting(settings, "ImageFiles", (_ImageFiles != Constants.ImageFiles) ? _ImageFiles.Replace(" ", "") : "", false); settings = SettingService.SetSetting(settings, "UploadableFiles", (_UploadableFiles != Constants.UploadableFiles) ? _UploadableFiles.Replace(" ", "") : "", false); - await SettingService.UpdateSiteSettingsAsync(settings, site.SiteId); + //text editor + settings = SettingService.SetSetting(settings, "TextEditorProvider", _textEditorProvider); - await logger.LogInformation("Site Settings Saved {Site}", site); + await SettingService.UpdateSiteSettingsAsync(settings, site.SiteId); - NavigationManager.NavigateTo(NavigateUrl(), true); // reload - } - } - else - { - AddModuleMessage(Localizer["Message.Required.SiteName"], MessageType.Warning); - } - } - catch (Exception ex) - { - await logger.LogError(ex, "Error Saving Site {SiteId} {Error}", PageState.Site.SiteId, ex.Message); - AddModuleMessage(Localizer["Error.SaveSite"], MessageType.Error); - } - } - else - { - AddModuleMessage(SharedLocalizer["Message.InfoRequired"], MessageType.Warning); - } - } + await logger.LogInformation("Site Settings Saved {Site}", site); - private async Task DeleteSite() - { - try - { - var aliases = await AliasService.GetAliasesAsync(); - if (aliases.Any(item => item.SiteId != PageState.Site.SiteId || item.TenantId != PageState.Site.TenantId)) - { - await SiteService.DeleteSiteAsync(PageState.Site.SiteId); - await logger.LogInformation("Site Deleted {SiteId}", PageState.Site.SiteId); + if(_textEditorProviderSettingsControl != null) + { + await _textEditorProviderSettingsControl.UpdateSettings(); + } - foreach (Alias alias in aliases.Where(item => item.SiteId == PageState.Site.SiteId && item.TenantId == PageState.Site.TenantId)) - { - await AliasService.DeleteAliasAsync(alias.AliasId); - } + NavigationManager.NavigateTo(NavigateUrl(), true); // reload + } + } + else + { + AddModuleMessage(Localizer["Message.Required.SiteName"], MessageType.Warning); + } + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Saving Site {SiteId} {Error}", PageState.Site.SiteId, ex.Message); + AddModuleMessage(Localizer["Error.SaveSite"], MessageType.Error); + } + } + else + { + AddModuleMessage(SharedLocalizer["Message.InfoRequired"], MessageType.Warning); + } + } - var redirect = aliases.First(item => item.SiteId != PageState.Site.SiteId || item.TenantId != PageState.Site.TenantId); - NavigationManager.NavigateTo(PageState.Uri.Scheme + "://" + redirect.Name, true); - } - else - { - AddModuleMessage(Localizer["Message.FailAuth.DeleteSite"], MessageType.Warning); - } - } - catch (Exception ex) - { - await logger.LogError(ex, "Error Deleting Site {SiteId} {Error}", PageState.Site.SiteId, ex.Message); - AddModuleMessage(Localizer["Error.DeleteSite"], MessageType.Error); - } - } + private async Task DeleteSite() + { + try + { + var aliases = await AliasService.GetAliasesAsync(); + if (aliases.Any(item => item.SiteId != PageState.Site.SiteId || item.TenantId != PageState.Site.TenantId)) + { + await SiteService.DeleteSiteAsync(PageState.Site.SiteId); + await logger.LogInformation("Site Deleted {SiteId}", PageState.Site.SiteId); - private async Task SendEmail() - { - if (_smtphost != "" && _smtpport != "" && _smtpsender != "") - { - try - { - var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId); - settings = SettingService.SetSetting(settings, "SMTPHost", _smtphost, true); - settings = SettingService.SetSetting(settings, "SMTPPort", _smtpport, true); - settings = SettingService.SetSetting(settings, "SMTPSSL", _smtpssl, true); - settings = SettingService.SetSetting(settings, "SMTPUsername", _smtpusername, true); - settings = SettingService.SetSetting(settings, "SMTPPassword", _smtppassword, true); - settings = SettingService.SetSetting(settings, "SMTPSender", _smtpsender, true); - await SettingService.UpdateSiteSettingsAsync(settings, PageState.Site.SiteId); - await logger.LogInformation("Site SMTP Settings Saved"); + foreach (Alias alias in aliases.Where(item => item.SiteId == PageState.Site.SiteId && item.TenantId == PageState.Site.TenantId)) + { + await AliasService.DeleteAliasAsync(alias.AliasId); + } - await NotificationService.AddNotificationAsync(new Notification(PageState.Site.SiteId, PageState.User, PageState.Site.Name + " SMTP Configuration Test", "SMTP Server Is Configured Correctly.")); - AddModuleMessage(Localizer["Info.Smtp.SaveSettings"], MessageType.Info); + var redirect = aliases.First(item => item.SiteId != PageState.Site.SiteId || item.TenantId != PageState.Site.TenantId); + NavigationManager.NavigateTo(PageState.Uri.Scheme + "://" + redirect.Name, true); + } + else + { + AddModuleMessage(Localizer["Message.FailAuth.DeleteSite"], MessageType.Warning); + } + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Deleting Site {SiteId} {Error}", PageState.Site.SiteId, ex.Message); + AddModuleMessage(Localizer["Error.DeleteSite"], MessageType.Error); + } + } + + private async Task SendEmail() + { + if (_smtphost != "" && _smtpport != "" && _smtpsender != "") + { + try + { + var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId); + settings = SettingService.SetSetting(settings, "SMTPHost", _smtphost, true); + settings = SettingService.SetSetting(settings, "SMTPPort", _smtpport, true); + settings = SettingService.SetSetting(settings, "SMTPSSL", _smtpssl, true); + settings = SettingService.SetSetting(settings, "SMTPUsername", _smtpusername, true); + settings = SettingService.SetSetting(settings, "SMTPPassword", _smtppassword, true); + settings = SettingService.SetSetting(settings, "SMTPSender", _smtpsender, true); + await SettingService.UpdateSiteSettingsAsync(settings, PageState.Site.SiteId); + await logger.LogInformation("Site SMTP Settings Saved"); + + await NotificationService.AddNotificationAsync(new Notification(PageState.Site.SiteId, PageState.User, PageState.Site.Name + " SMTP Configuration Test", "SMTP Server Is Configured Correctly.")); + AddModuleMessage(Localizer["Info.Smtp.SaveSettings"], MessageType.Info); await ScrollToPageTop(); } - catch (Exception ex) - { - await logger.LogError(ex, "Error Testing SMTP Configuration"); - AddModuleMessage(Localizer["Error.Smtp.TestConfig"], MessageType.Error); - } - } - else - { - AddModuleMessage(Localizer["Message.Required.Smtp"], MessageType.Warning); - } - } + catch (Exception ex) + { + await logger.LogError(ex, "Error Testing SMTP Configuration"); + AddModuleMessage(Localizer["Error.Smtp.TestConfig"], MessageType.Error); + } + } + else + { + AddModuleMessage(Localizer["Message.Required.Smtp"], MessageType.Warning); + } + } - private void ToggleSMTPPassword() - { - if (_smtppasswordtype == "password") - { - _smtppasswordtype = "text"; - _togglesmtppassword = SharedLocalizer["HidePassword"]; - } - else - { - _smtppasswordtype = "password"; - _togglesmtppassword = SharedLocalizer["ShowPassword"]; - } - } + private void ToggleSMTPPassword() + { + if (_smtppasswordtype == "password") + { + _smtppasswordtype = "text"; + _togglesmtppassword = SharedLocalizer["HidePassword"]; + } + else + { + _smtppasswordtype = "password"; + _togglesmtppassword = SharedLocalizer["ShowPassword"]; + } + } - private async Task GetAliases() - { - if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) - { - _aliases = await AliasService.GetAliasesAsync(); - _aliases = _aliases.Where(item => item.SiteId == PageState.Site.SiteId && item.TenantId == PageState.Site.TenantId).OrderBy(item => item.AliasId).ToList(); - } - } + private async Task GetAliases() + { + if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) + { + _aliases = await AliasService.GetAliasesAsync(); + _aliases = _aliases.Where(item => item.SiteId == PageState.Site.SiteId && item.TenantId == PageState.Site.TenantId).OrderBy(item => item.AliasId).ToList(); + } + } - private void AddAlias() - { - _aliases.Add(new Alias { AliasId = 0, Name = "", IsDefault = false }); - _aliasid = 0; - _aliasname = ""; - _defaultalias = "False"; - StateHasChanged(); - } + private void AddAlias() + { + _aliases.Add(new Alias { AliasId = 0, Name = "", IsDefault = false }); + _aliasid = 0; + _aliasname = ""; + _defaultalias = "False"; + StateHasChanged(); + } - private void EditAlias(Alias alias) - { - _aliasid = alias.AliasId; - _aliasname = alias.Name; - _defaultalias = alias.IsDefault.ToString(); - StateHasChanged(); - } + private void EditAlias(Alias alias) + { + _aliasid = alias.AliasId; + _aliasname = alias.Name; + _defaultalias = alias.IsDefault.ToString(); + StateHasChanged(); + } - private async Task DeleteAlias(Alias alias) - { - if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) - { - await AliasService.DeleteAliasAsync(alias.AliasId); - await GetAliases(); - StateHasChanged(); - } - } + private async Task DeleteAlias(Alias alias) + { + if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) + { + await AliasService.DeleteAliasAsync(alias.AliasId); + await GetAliases(); + StateHasChanged(); + } + } private async Task SaveAlias() { @@ -878,11 +919,42 @@ } } - private async Task CancelAlias() - { - await GetAliases(); - _aliasid = -1; - _aliasname = ""; - StateHasChanged(); - } + private async Task CancelAlias() + { + await GetAliases(); + _aliasid = -1; + _aliasname = ""; + StateHasChanged(); + } + + private void TextEditorProviderChanged(ChangeEventArgs e) + { + _textEditorProvider = e.Value.ToString(); + LoadTextEditorProviderSettingsControl(); + + StateHasChanged(); + } + + private void LoadTextEditorProviderSettingsControl() + { + var provider = _textEditorProviders.FirstOrDefault(i => i.EditorType == _textEditorProvider); + var settingsType = provider != null && !string.IsNullOrEmpty(provider.SettingsType) ? Type.GetType(provider.SettingsType) : null; + if (settingsType != null) + { + _textEditorProviderSettings = builder => + { + builder.OpenComponent(0, settingsType); + builder.AddComponentReferenceCapture(1, (c) => + { + _textEditorProviderSettingsControl = (ISettingsControl)c; + }); + builder.CloseComponent(); + }; + } + else + { + _textEditorProviderSettings = null; + _textEditorProviderSettingsControl = null; + } + } } diff --git a/Oqtane.Client/Modules/Controls/QuillTextEditor.razor b/Oqtane.Client/Modules/Controls/QuillJSTextEditor.razor similarity index 83% rename from Oqtane.Client/Modules/Controls/QuillTextEditor.razor rename to Oqtane.Client/Modules/Controls/QuillJSTextEditor.razor index c64a5c6f..e0e86139 100644 --- a/Oqtane.Client/Modules/Controls/QuillTextEditor.razor +++ b/Oqtane.Client/Modules/Controls/QuillJSTextEditor.razor @@ -1,11 +1,12 @@ @namespace Oqtane.Modules.Controls @inherits ModuleControlBase @implements ITextEditor -@inject IStringLocalizer Localizer +@inject ISettingService SettingService +@inject IStringLocalizer Localizer
- @if (AllowRichText) + @if (_allowRichText) { @if (_richfilemanager) @@ -15,7 +16,7 @@
}
- @if (AllowFileManagement) + @if (_allowFileManagement) { } @@ -28,9 +29,9 @@
- @if (ToolbarContent != null) + @if (!string.IsNullOrEmpty(_toolbarContent)) { - @ToolbarContent + @((MarkupString)_toolbarContent) } else { @@ -66,7 +67,7 @@
} - @if (AllowRawHtml) + @if (_allowRawHtml) { @if (_rawfilemanager) @@ -76,7 +77,7 @@
}
- @if (AllowFileManagement) + @if (_allowFileManagement) { } @@ -106,6 +107,14 @@ private FileManager _fileManager; private string _activetab = "Rich"; + private bool _allowFileManagement = false; + private bool _allowRawHtml = false; + private bool _allowRichText = false; + private string _theme = "snow"; + private string _debugLevel = "info"; + private string _toolbarContent = string.Empty; + private bool _settingsLoaded; + private ElementReference _editorElement; private ElementReference _toolBar; private bool _richfilemanager = false; @@ -121,38 +130,22 @@ private bool _contentchanged = false; private int _editorIndex; - [Parameter] - public bool AllowFileManagement{ get; set; } - - [Parameter] - public bool AllowRichText { get; set; } = true; - - [Parameter] - public bool AllowRawHtml { get; set; } = true; - [Parameter] public bool ReadOnly { get; set; } [Parameter] public string Placeholder { get; set; } - [Parameter] - public string Theme { get; set; } = "snow"; - - [Parameter] - public string DebugLevel { get; set; } = "info"; - - [Parameter] - public RenderFragment ToolbarContent { get; set; } - public override List Resources { get; set; } = new List() { new Resource { ResourceType = ResourceType.Script, Bundle = "Quill", Url = "js/quill.min.js", Location = ResourceLocation.Body }, new Resource { ResourceType = ResourceType.Script, Bundle = "Quill", Url = "js/quill-blot-formatter.min.js", Location = ResourceLocation.Body }, - new Resource { ResourceType = ResourceType.Script, Bundle = "Quill", Url = "js/quill-interop.js", Location = ResourceLocation.Body } + new Resource { ResourceType = ResourceType.Script, Bundle = "Quill", Url = "js/quill-interop.js", Location = ResourceLocation.Body }, + new Resource { ResourceType = ResourceType.Stylesheet, Url = "css/quill/quill.bubble.css" }, + new Resource { ResourceType = ResourceType.Stylesheet, Url = "css/quill/quill.snow.css" } }; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { interop = new QuillEditorInterop(JSRuntime); @@ -160,11 +153,13 @@ { Placeholder = Localizer["Placeholder"]; } + + await LoadSettings(); } protected override void OnParametersSet() { - if (!AllowRichText) + if (!_allowRichText) { _activetab = "Raw"; } @@ -174,17 +169,17 @@ { await base.OnAfterRenderAsync(firstRender); - if (AllowRichText) + if (_allowRichText) { - if (firstRender) + if (_settingsLoaded && !_initialized) { await interop.CreateEditor( _editorElement, _toolBar, ReadOnly, Placeholder, - Theme, - DebugLevel); + _theme, + _debugLevel); await interop.LoadEditorContent(_editorElement, _richhtml); @@ -202,6 +197,8 @@ // reload editor if Content passed to component has changed await interop.LoadEditorContent(_editorElement, _richhtml); _originalrichhtml = await interop.GetHtml(_editorElement); + + _contentchanged = false; } else { @@ -215,8 +212,6 @@ } } } - - _contentchanged = false; } } @@ -224,10 +219,14 @@ { _richhtml = content; _rawhtml = content; - _originalrawhtml = _rawhtml; // preserve for comparison later _originalrichhtml = ""; _richhtml = content; - _contentchanged = content != _originalrawhtml; + if (!_contentchanged) + { + _contentchanged = content != _originalrawhtml; + } + + _originalrawhtml = _rawhtml; // preserve for comparison later StateHasChanged(); } @@ -243,7 +242,7 @@ { var richhtml = ""; - if (AllowRichText) + if (_allowRichText) { richhtml = await interop.GetHtml(_editorElement); } @@ -290,7 +289,7 @@ { var richhtml = ""; - if (AllowRichText) + if (_allowRichText) { richhtml = await interop.GetHtml(_editorElement); } @@ -362,4 +361,24 @@ } StateHasChanged(); } + + private async Task LoadSettings() + { + try + { + var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId); + _allowFileManagement = SettingService.GetSetting(settings, "QuillTextEditor_AllowFileManagement", "true") == "true"; + _allowRawHtml = SettingService.GetSetting(settings, "QuillTextEditor_AllowRawHtml", "true") == "true"; + _allowRichText = SettingService.GetSetting(settings, "QuillTextEditor_AllowRichText", "true") == "true"; + _theme = SettingService.GetSetting(settings, "QuillTextEditor_Theme", "snow"); + _debugLevel = SettingService.GetSetting(settings, "QuillTextEditor_DebugLevel", "info"); + _toolbarContent = SettingService.GetSetting(settings, "QuillTextEditor_ToolbarContent", string.Empty); + + _settingsLoaded = true; + } + catch (Exception ex) + { + AddModuleMessage(ex.Message, MessageType.Error); + } + } } diff --git a/Oqtane.Client/Modules/Controls/QuillJSTextEditorSettings.razor b/Oqtane.Client/Modules/Controls/QuillJSTextEditorSettings.razor new file mode 100644 index 00000000..fb41a2ef --- /dev/null +++ b/Oqtane.Client/Modules/Controls/QuillJSTextEditorSettings.razor @@ -0,0 +1,99 @@ +@namespace Oqtane.Modules.Controls +@inherits ModuleBase +@inject ISettingService SettingService +@implements Oqtane.Interfaces.ISettingsControl +@inject IStringLocalizer Localizer +@inject IStringLocalizer SharedLocalizer + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+