From 864f7fa8a03c2dc81380c34555baeb3db1e1f848 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 28 Jun 2025 21:23:02 +0200 Subject: [PATCH] Apps page improvements. (#1235) * Apps page improvements. * Fix tests * Fix e2e --- backend/i18n/frontend_en.json | 3 + backend/i18n/frontend_fr.json | 3 + backend/i18n/frontend_it.json | 3 + backend/i18n/frontend_nl.json | 3 + backend/i18n/frontend_pt.json | 3 + backend/i18n/frontend_zh.json | 3 + backend/i18n/source/frontend_en.json | 3 + .../Apps/Templates/Template.cs | 8 +- .../Apps/Templates/TemplatesClient.cs | 115 ++++++++++++++++-- .../Templates/Models/TemplateDto.cs | 10 ++ .../Templates/TemplatesController.cs | 5 +- .../src/Squidex/wwwroot/images/add-blog.svg | 38 ++++++ .../Squidex/wwwroot/images/add-profile.svg | 26 ++++ .../Apps/Templates/TemplatesClientTests.cs | 2 +- .../features/apps/pages/app.component.html | 4 +- .../features/apps/pages/app.component.scss | 73 ++++++++++- .../apps/pages/apps-page.component.html | 98 ++++++++------- .../apps/pages/apps-page.component.scss | 88 ++------------ .../apps/pages/apps-page.component.ts | 11 +- .../pages/templates/template.component.html | 31 ----- .../pages/templates/template.component.scss | 17 --- .../pages/templates/template.component.ts | 65 ---------- .../templates/templates-page.component.html | 37 ------ .../templates/templates-page.component.scss | 0 .../templates/templates-page.component.ts | 52 -------- frontend/src/app/features/settings/routes.ts | 14 --- .../settings/settings-menu.component.html | 4 - .../shared/components/app-form.component.html | 80 ++++++++++-- .../shared/components/app-form.component.scss | 31 ++++- .../shared/components/app-form.component.ts | 10 +- frontend/src/app/shared/model/generated.ts | 12 ++ .../shared/services/templates.service.spec.ts | 4 +- .../app/shared/services/templates.service.ts | 2 +- .../app/shared/state/template.state.spec.ts | 16 ++- .../src/app/shared/state/templates.state.ts | 3 + .../pages/internal/apps-menu.component.html | 6 +- .../pages/internal/apps-menu.component.ts | 5 +- frontend/src/app/theme/_bootstrap-vars.scss | 2 + frontend/src/app/theme/_common.scss | 4 + frontend/src/app/theme/_forms.scss | 11 +- tools/e2e/tests/pages/apps.ts | 13 +- 41 files changed, 514 insertions(+), 404 deletions(-) create mode 100644 backend/src/Squidex/wwwroot/images/add-blog.svg create mode 100644 backend/src/Squidex/wwwroot/images/add-profile.svg delete mode 100644 frontend/src/app/features/settings/pages/templates/template.component.html delete mode 100644 frontend/src/app/features/settings/pages/templates/template.component.scss delete mode 100644 frontend/src/app/features/settings/pages/templates/template.component.ts delete mode 100644 frontend/src/app/features/settings/pages/templates/templates-page.component.html delete mode 100644 frontend/src/app/features/settings/pages/templates/templates-page.component.scss delete mode 100644 frontend/src/app/features/settings/pages/templates/templates-page.component.ts diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index fa0d64df8..c238f6753 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -10,6 +10,7 @@ "api.title": "API", "apps.allApps": "All Apps", "apps.allTeams": "All Teams", + "apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives", "apps.appLoadFailed": "Failed to load app. Please reload.", "apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.", "apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.", @@ -42,6 +43,8 @@ "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.removeImage": "Remove image", "apps.removeImageFailed": "Failed to remove app image. Please reload.", + "apps.selectAppTemplate": "Select a Template", + "apps.template": "Template", "apps.transfer": "Transfer", "apps.transferFailed": "Failed to transfer the app. Please reload.", "apps.transferTitle": "Transfer to team", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index 5a631e8a1..5a7acab7e 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -10,6 +10,7 @@ "api.title": "API", "apps.allApps": "Toutes les applications", "apps.allTeams": "Toutes les équipes", + "apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives", "apps.appLoadFailed": "Échec du chargement de l'application. Veuillez recharger.", "apps.appNameHint": "Vous ne pouvez utiliser que des lettres, des chiffres et des tirets et pas plus de 40 caractères.", "apps.appNameValidationMessage": "Le nom peut contenir des lettres minuscules (az), des chiffres et des tirets entre.", @@ -42,6 +43,8 @@ "apps.loadSettingsFailed": "Échec de la mise à jour des paramètres de l'interface utilisateur. Veuillez recharger.", "apps.removeImage": "Supprimer l'image", "apps.removeImageFailed": "Échec de la suppression de l'image de l'application. Veuillez recharger.", + "apps.selectAppTemplate": "Select a Template", + "apps.template": "Template", "apps.transfer": "Transfert", "apps.transferFailed": "Échec du transfert de l'application. Veuillez recharger.", "apps.transferTitle": "Transférer à l'équipe", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 642864932..c45ccb6b0 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -10,6 +10,7 @@ "api.title": "API", "apps.allApps": "Tutte le Apps", "apps.allTeams": "All Teams", + "apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives", "apps.appLoadFailed": "Non è stato possibile caricare l'App. Per favore ricarica.", "apps.appNameHint": "Puoi utilizzare solo lettere, numeri e trattini e non più di 40 caratteri.", "apps.appNameValidationMessage": "Il nome può contenere lettere minuscole (a-z), numeri e trattini all'interno.", @@ -42,6 +43,8 @@ "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.removeImage": "Rimuovi l'immagine", "apps.removeImageFailed": "Non è stato possibile rimuovere l'immagine dell'app. Per favore ricarica.", + "apps.selectAppTemplate": "Select a Template", + "apps.template": "Template", "apps.transfer": "Transfer", "apps.transferFailed": "Failed to transfer the app. Please reload.", "apps.transferTitle": "Transfer to team", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 779b3e50d..a5d9df187 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -10,6 +10,7 @@ "api.title": "API", "apps.allApps": "Alle apps", "apps.allTeams": "All Teams", + "apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives", "apps.appLoadFailed": "Kan app niet laden. Laad opnieuw.", "apps.appNameHint": "Je kunt alleen letters, cijfers en streepjes gebruiken en niet meer dan 40 tekens.", "apps.appNameValidationMessage": "Naam mag kleine letters (a-z), cijfers en streepjes tussen bevatten.", @@ -42,6 +43,8 @@ "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.removeImage": "Afbeelding verwijderen", "apps.removeImageFailed": "Verwijderen van app-afbeelding is mislukt. Laad opnieuw.", + "apps.selectAppTemplate": "Select a Template", + "apps.template": "Template", "apps.transfer": "Transfer", "apps.transferFailed": "Failed to transfer the app. Please reload.", "apps.transferTitle": "Transfer to team", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index 3444ada57..798147b78 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -10,6 +10,7 @@ "api.title": "API", "apps.allApps": "Todas as aplicações", "apps.allTeams": "Todas as Equipas", + "apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives", "apps.appLoadFailed": "Falha na aplicação. Por favor, recarregue.", "apps.appNameHint": "Só pode utilizar letras, números e traços e não mais de 40 caracteres.", "apps.appNameValidationMessage": "O nome pode conter letras minúsculas (a-z), números e traços entre.", @@ -42,6 +43,8 @@ "apps.loadSettingsFailed": "Falhou na atualização das definições de UI. Por favor, recarregue.", "apps.removeImage": "Remover imagem", "apps.removeImageFailed": "Falhou na remoção da imagem da aplicação. Por favor, recarregue.", + "apps.selectAppTemplate": "Select a Template", + "apps.template": "Template", "apps.transfer": "Transferência", "apps.transferFailed": "Falhou na transferência da aplicação. Por favor recarregue.", "apps.transferTitle": "Transferência para a equipa", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index d9b02e36e..7b358d38b 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -10,6 +10,7 @@ "api.title": "API", "apps.allApps": "所有应用程序", "apps.allTeams": "All Teams", + "apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives", "apps.appLoadFailed": "加载应用失败。请重新加载。", "apps.appNameHint": "您只能使用字母、数字和破折号,并且不能超过 40 个字符。", "apps.appNameValidationMessage": "名称可以包含小写字母 (a-z)、数字和破折号。", @@ -42,6 +43,8 @@ "apps.loadSettingsFailed": "更新界面设置失败。请重新加载。", "apps.removeImage": "删除图片", "apps.removeImageFailed": "删除应用图片失败。请重新加载。", + "apps.selectAppTemplate": "Select a Template", + "apps.template": "Template", "apps.transfer": "Transfer", "apps.transferFailed": "Failed to transfer the app. Please reload.", "apps.transferTitle": "Transfer to team", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index fa0d64df8..c238f6753 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -10,6 +10,7 @@ "api.title": "API", "apps.allApps": "All Apps", "apps.allTeams": "All Teams", + "apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives", "apps.appLoadFailed": "Failed to load app. Please reload.", "apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.", "apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.", @@ -42,6 +43,8 @@ "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.removeImage": "Remove image", "apps.removeImageFailed": "Failed to remove app image. Please reload.", + "apps.selectAppTemplate": "Select a Template", + "apps.template": "Template", "apps.transfer": "Transfer", "apps.transferFailed": "Failed to transfer the app. Please reload.", "apps.transferTitle": "Transfer to team", diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs index b46740951..a18dfd139 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs @@ -9,4 +9,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates; -public sealed record Template(string Name, string Title, string Description, bool IsStarter); +public sealed record Template( + string Name, + string Title, + string Description, + string Details, + bool IsStarter, + string? Logo); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs index 21f8bc81e..f2d6e7907 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs @@ -5,8 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.CodeDom; using System.Text.RegularExpressions; +using Markdig; +using Markdig.Renderers.Normalize; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; using Microsoft.Extensions.Options; +using Squidex.ClientLibrary; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Templates; @@ -41,7 +47,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory return null; } - public async Task> GetTemplatesAsync( + public async Task> GetTemplatesAsync(bool includeDetails = false, CancellationToken ct = default) { var httpClient = httpClientFactory.CreateClient(); @@ -54,15 +60,27 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory var text = await httpClient.GetStringAsync(url, ct); - foreach (Match match in RegexTemplate.Matches(text).OfType()) + foreach (var match in RegexTemplate.Matches(text).OfType()) { - var title = match.Groups["Title"].Value; + var templateName = match.Groups["Name"].Value; + var templateTitle = match.Groups["Title"].Value; + + const string StarterPrefix = "Starter "; + var isStarter = templateTitle.StartsWith(StarterPrefix, StringComparison.OrdinalIgnoreCase); + if (isStarter) + { + templateTitle = templateTitle[StarterPrefix.Length..].TrimStart(' ', ':'); + } + + var (details, logo) = await GetDetailCoreAsync(templateName, ct); result.Add(new Template( - match.Groups["Name"].Value, - title, + templateName, + templateTitle, match.Groups["Description"].Value, - title.StartsWith("Starter ", StringComparison.OrdinalIgnoreCase))); + GetSummary(details, includeDetails), + isStarter, + logo)); } } @@ -74,23 +92,100 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory { Guard.NotNullOrEmpty(name); + var (text, _) = await GetDetailCoreAsync(name, ct); + return text; + } + + private async Task<(string? Text, string? Logo)> GetDetailCoreAsync(string name, + CancellationToken ct = default) + { var httpClient = httpClientFactory.CreateClient(); foreach (var repository in options.Repositories.OrEmpty()) { - var url = $"{repository.ContentUrl}/{name}/README.md"; + var url = new Uri($"{repository.ContentUrl}/{name}/README.md", UriKind.Absolute); var response = await httpClient.GetAsync(url, ct); - if (response.IsSuccessStatusCode) { - return await response.Content.ReadAsStringAsync(ct); + var text = await response.Content.ReadAsStringAsync(ct); + + string? logo = null; + text = BuildLogoRegex().Replace(text, match => + { + var imageRelative = new Uri(match.Groups["Url"].Value, UriKind.Relative); + var imageAbsolute = new Uri(url, imageRelative); + + logo = imageAbsolute.ToString(); + return string.Empty; + }); + + return (text, logo); } } - return null; + return default; + } + + private static string GetSummary(string? markdown, bool includeDetails) + { + if (string.IsNullOrWhiteSpace(markdown) || !includeDetails) + { + return string.Empty; + } + + var document = Markdown.Parse(markdown); + var outputWriter = new StringWriter(); + var outputRenderer = new NormalizeRenderer(outputWriter); + var headerCount = 0; + foreach (var block in document) + { + if (block is HeadingBlock heading || IsUsageBlock(block)) + { + headerCount++; + if (headerCount == 2) + { + break; + } + } + else if (headerCount == 1) + { + outputRenderer.Render(block); + } + } + + static bool IsUsageBlock(Block block) + { + return block is ParagraphBlock p && IsUsageLiteral(p.Inline); + } + + static bool IsUsageLiteral(Inline? inline) + { + if (inline is LiteralInline literal) + { + return literal.Content.AsSpan().Trim().Equals("Usage", StringComparison.Ordinal); + } + + if (inline is ContainerInline container) + { + foreach (var child in container) + { + if (IsUsageLiteral(child)) + { + return true; + } + } + } + + return false; + } + + return outputWriter.ToString(); } [GeneratedRegex("\\* \\[(?.*)\\]\\((?<Name>.*)\\/README\\.md\\): (?<Description>.*)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)] private static partial Regex BuildTemplateRegex(); + + [GeneratedRegex("Logo: \\[Logo\\]\\((?<Url>(.*))\\)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)] + private static partial Regex BuildLogoRegex(); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs index 6a53aa3a7..02ed82115 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs @@ -28,11 +28,21 @@ public sealed class TemplateDto : Resource /// </summary> public string Description { get; set; } + /// <summary> + /// The details of the template. + /// </summary> + public string Details { get; set; } + /// <summary> /// True, if the template is a starter. /// </summary> public bool IsStarter { get; set; } + /// <summary> + /// The optional logo. + /// </summary> + public string? Logo { get; set; } + public static TemplateDto FromDomain(Template template, Resources resources) { var result = SimpleMapper.Map(template, new TemplateDto()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Templates/TemplatesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Templates/TemplatesController.cs index 7fd7d7e50..a30554125 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Templates/TemplatesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Templates/TemplatesController.cs @@ -22,14 +22,15 @@ public sealed class TemplatesController(ICommandBus commandBus, TemplatesClient /// <summary> /// Get all templates. /// </summary> + /// <param name="includeDetails">Also include the details.</param> /// <response code="200">Templates returned.</response> [HttpGet] [Route("templates/")] [ProducesResponseType(typeof(TemplatesDto), StatusCodes.Status200OK)] [ApiPermission] - public async Task<IActionResult> GetTemplates() + public async Task<IActionResult> GetTemplates([FromQuery] bool includeDetails) { - var templates = await templatesClient.GetTemplatesAsync(HttpContext.RequestAborted); + var templates = await templatesClient.GetTemplatesAsync(includeDetails, HttpContext.RequestAborted); var response = TemplatesDto.FromDomain(templates, Resources); diff --git a/backend/src/Squidex/wwwroot/images/add-blog.svg b/backend/src/Squidex/wwwroot/images/add-blog.svg new file mode 100644 index 000000000..cecf13fa4 --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/add-blog.svg @@ -0,0 +1,38 @@ +<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 64 64" xml:space="preserve"> + <style id="style8137" type="text/css"> + .st15{fill:#fff} + </style> + <style id="style8137-2" type="text/css"> + .st15{fill:#fff} + </style> + <style id="style8137-0" type="text/css"> + .st15{fill:#fff} + </style> + <g id="g4726" transform="translate(-67.875 31.5)"> + <g id="g8143-8" transform="translate(67.875 -31.5)"> + <path id="path8145-0" fill="#b4bcc1" d="M11.8 16c.2.4.2.9.2 1.3V19h1v-1.6c0-.7-.1-1.4-.4-2l-.1-3.1c-.4-1.2-.3-2.5.4-3.5.6-1 1.6-1.6 2.7-1.7H23.2c2.4 0 4.6.9 6.3 2.6l4.7 4.7c2.1 2.1 4.8 3.2 7.7 3.2H50v-2h-8.1c-2.4 0-4.6-.9-6.3-2.6l-4.7-4.7C28.8 6.1 26.1 5 23.2 5h-7.8c-1.7.2-3.2 1.1-4.2 2.6-1 1.6-1.2 3.5-.6 5.3l1.2 3.1z"/> + </g> + <g id="g8147-3" transform="translate(67.875 -31.5)"> + <path id="path8149-6" fill="#d7d7f9" d="M3 24v-4.5C3 15.9 5.9 13 9.5 13h12.8c1.7 0 3.4.7 4.6 1.9l7.2 9.2c1.2 1.2 2.9 1.9 4.6 1.9h7.8C55.1 26 61 30.9 61 39.5v15c0 3.6-3.9 6.5-7.5 6.5h-44C5.9 61 3 58.1 3 54.5V24z"/> + <path id="path8151-3" fill="#2e3842" d="M53.5 62h-44C5.4 62 2 58.6 2 54.5v-35C2 15.4 5.4 12 9.5 12h12.8c2 0 3.9.8 5.3 2.2l.1.1 7.2 9.2c1 1 2.4 1.6 3.8 1.6h7.8C55.9 25 62 30.7 62 39.5v15c0 4.3-4.5 7.5-8.5 7.5zm-44-48c-3 0-5.5 2.5-5.5 5.5v35.1c0 3 2.5 5.5 5.5 5.5h44.1c2.9 0 6.5-2.4 6.5-5.5v-15C60 31.8 54.8 27 46.5 27h-7.8c-2 0-3.9-.8-5.3-2.2l-.1-.1-7.2-9.2c-1-1-2.4-1.6-3.8-1.6L9.633 14z"/> + </g> + <g id="g8153" transform="translate(67.875 -31.5)"> + <path id="path8155" fill="#ededf9" d="M53 60H10c-3.8 0-6-2.2-6-6V20c0-3.8 2.2-6 6-6h12c2 0 3.6 1.2 4.7 2.3l.1.1 7 8.9c.6.5 2.1 1.7 4.5 1.5h7.3C54.7 26.9 60 31.3 60 39v15c0 3.6-2.8 6-7 6zM10 16c-2.7 0-4 1.3-4 4v34c0 2.7 1.3 4 4 4h43c2.3 0 5-1 5-4V39c0-8.8-7.9-10.1-12.5-10.1h-7.2c-2.2.1-4.4-.6-6-2.1l-.1-.1-7-8.9C24 16.5 23.1 16 22 16H10z"/> + </g> + <g id="g8197-0" transform="translate(67.875 -31.5)"> + <circle id="circle8199-9" cx="51.9" cy="12.1" r="11.1" fill="#3389ff"/> + <path id="path8201-7" fill="#fff" d="M51.9 24.2c-6.7 0-12.1-5.4-12.1-12.1C39.8 5.4 45.2 0 51.9 0 58.6 0 64 5.4 64 12.1c0 6.7-5.4 12.1-12.1 12.1zm0-22.2c-5.6 0-10.1 4.5-10.1 10.1s4.5 10.1 10.1 10.1S62 17.7 62 12.1 57.5 2 51.9 2z" class="st15"/> + </g> + <g id="g8203-4" transform="translate(67.875 -31.5)"> + <path id="path8205-6" fill="#fff" d="M57 13H47c-.6 0-1-.4-1-1s.4-1 1-1h10c.6 0 1 .4 1 1s-.4 1-1 1z" class="st15"/> + </g> + <g id="g8207-9" transform="translate(67.875 -31.5)"> + <path id="path8209-3" fill="#fff" d="M52 18c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1s1 .4 1 1v10c0 .6-.4 1-1 1z" class="st15"/> + </g> + <g id="g4685" stroke="#b5b5ea" stroke-opacity="1" transform="matrix(.77563 0 0 .7719 73.711 -21.893)"> + <path id="path4174" fill="#b5b5ea" fill-opacity="1" stroke-width=".082" d="M12.888 47.387c-.96 0-1.775.34-2.447 1.018-.672.68-1.007 1.504-1.007 2.475 0 .97.335 1.795 1.007 2.474a3.313 3.313 0 002.447 1.019c.96 0 1.774-.34 2.446-1.019.672-.679 1.007-1.504 1.007-2.474 0-.97-.335-1.796-1.007-2.475a3.312 3.312 0 00-2.446-1.018z"/> + <path id="path4356" fill="none" fill-rule="evenodd" stroke-dasharray="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="4" stroke-width="3.877" d="M10.506 31.413C33.49 31.538 32.491 53.4 32.491 53.4"/> + <path id="path4356-4" fill="none" fill-rule="evenodd" stroke-dasharray="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="4" stroke-width="3.877" d="M10.344 39.427c14.616.08 13.982 13.986 13.982 13.986"/> + </g> + </g> +</svg> \ No newline at end of file diff --git a/backend/src/Squidex/wwwroot/images/add-profile.svg b/backend/src/Squidex/wwwroot/images/add-profile.svg new file mode 100644 index 000000000..e9b78a32b --- /dev/null +++ b/backend/src/Squidex/wwwroot/images/add-profile.svg @@ -0,0 +1,26 @@ +<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 64 64" xml:space="preserve"> + <style id="style2" type="text/css"> + .st7{fill:#fff} + </style> + <g id="g8143"> + <path id="path8145" fill="#b4bcc1" d="M11.8 16c.2.4.2.9.2 1.3V19h1v-1.6c0-.7-.1-1.4-.4-2l-.1-3.1c-.4-1.2-.3-2.5.4-3.5.6-1 1.6-1.6 2.7-1.7H23.2c2.4 0 4.6.9 6.3 2.6l4.7 4.7c2.1 2.1 4.8 3.2 7.7 3.2H50v-2h-8.1c-2.4 0-4.6-.9-6.3-2.6l-4.7-4.7C28.8 6.1 26.1 5 23.2 5h-7.8c-1.7.2-3.2 1.1-4.2 2.6-1 1.6-1.2 3.5-.6 5.3l1.2 3.1z"/> + </g> + <g id="g8147"> + <path id="path8149" fill="#d7d7f9" d="M3 24v-4.5C3 15.9 5.9 13 9.5 13h12.8c1.7 0 3.4.7 4.6 1.9l7.2 9.2c1.2 1.2 2.9 1.9 4.6 1.9h7.8C55.1 26 61 30.9 61 39.5v15c0 3.6-3.9 6.5-7.5 6.5h-44C5.9 61 3 58.1 3 54.5V24z"/> + <path id="path8151" fill="#2e3842" d="M53.5 62h-44C5.4 62 2 58.6 2 54.5v-35C2 15.4 5.4 12 9.5 12h12.8c2 0 3.9.8 5.3 2.2l.1.1 7.2 9.2c1 1 2.4 1.6 3.8 1.6h7.8C55.9 25 62 30.7 62 39.5v15c0 4.3-4.5 7.5-8.5 7.5zm-44-48c-3 0-5.5 2.5-5.5 5.5v35.1c0 3 2.5 5.5 5.5 5.5h44.1c2.9 0 6.5-2.4 6.5-5.5v-15C60 31.8 54.8 27 46.5 27h-7.8c-2 0-3.9-.8-5.3-2.2l-.1-.1-7.2-9.2c-1-1-2.4-1.6-3.8-1.6H9.5v.1z"/> + </g> + <g id="g8153"> + <path id="path8155" fill="#ededf9" d="M53 60H10c-3.8 0-6-2.2-6-6V20c0-3.8 2.2-6 6-6h12c2 0 3.6 1.2 4.7 2.3l.1.1 7 8.9c.6.5 2.1 1.7 4.5 1.5h7.3C54.7 26.9 60 31.3 60 39v15c0 3.6-2.8 6-7 6zM10 16c-2.7 0-4 1.3-4 4v34c0 2.7 1.3 4 4 4h43c2.3 0 5-1 5-4V39c0-8.8-7.9-10.1-12.5-10.1h-7.2c-2.2.1-4.4-.6-6-2.1l-.1-.1-7-8.9C24 16.5 23.1 16 22 16H10z"/> + </g> + <g id="g8197"> + <circle id="circle8199" cx="51.9" cy="12.1" r="11.1" fill="#3389ff"/> + <path id="path8201" d="M51.9 24.2c-6.7 0-12.1-5.4-12.1-12.1S45.2 0 51.9 0 64 5.4 64 12.1s-5.4 12.1-12.1 12.1zm0-22.2c-5.6 0-10.1 4.5-10.1 10.1s4.5 10.1 10.1 10.1S62 17.7 62 12.1 57.5 2 51.9 2z" class="st7"/> + </g> + <g id="g8203"> + <path id="path8205" d="M57 13H47c-.6 0-1-.4-1-1s.4-1 1-1h10c.6 0 1 .4 1 1s-.4 1-1 1z" class="st7"/> + </g> + <g id="g8207"> + <path id="path8209" d="M52 18c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1s1 .4 1 1v10c0 .6-.4 1-1 1z" class="st7"/> + </g> + <path id="path36" fill="#b5b5ea" fill-opacity="1" stroke="#b5b5ea" stroke-dasharray="none" stroke-miterlimit="4" stroke-opacity="1" stroke-width=".5" d="M21.695 32.767c-3.123 0-5.67 2.547-5.67 5.667 0 1.56.588 3.48 1.55 5.105.466.787 1.04 1.514 1.701 2.065-1.409.179-2.892.223-4.081.759-1.893.852-3.423 2.247-4.087 4.235-.036.226 0 0-.036.214v.012a3.199 3.199 0 003.186 3.19h14.87a3.2 3.2 0 003.19-3.19l-.036-.222c-.66-1.998-2.188-3.394-4.081-4.245-1.19-.534-2.677-.58-4.087-.756.659-.552 1.233-1.277 1.697-2.063.962-1.625 1.551-3.544 1.551-5.105a5.678 5.678 0 00-5.667-5.667zm0 1.423a4.235 4.235 0 014.245 4.245c0 1.175-.519 2.973-1.352 4.381-.833 1.408-1.931 2.342-2.893 2.342-.962 0-2.06-.934-2.893-2.342-.832-1.407-1.354-3.206-1.354-4.38a4.238 4.238 0 014.247-4.246zm0 12.39c2.195 0 4.288.341 5.922 1.076 1.583.712 2.695 1.772 3.236 3.267-.058.93-.78 1.668-1.725 1.668h-14.87c-.94 0-1.662-.736-1.722-1.666.544-1.487 1.659-2.55 3.242-3.263 1.635-.736 3.728-1.082 5.916-1.082z"/> +</svg> \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs index 74f5ee1ca..0f8c2b1ec 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs @@ -45,7 +45,7 @@ public class TemplatesClientTests var templates = await sut.GetTemplatesAsync(); Assert.NotEmpty(templates); - Assert.Contains(templates, x => x.IsStarter); + Assert.Contains(templates, x => x.IsStarter && !string.IsNullOrWhiteSpace(x.Logo)); } [Fact] diff --git a/frontend/src/app/features/apps/pages/app.component.html b/frontend/src/app/features/apps/pages/app.component.html index 5acef1be7..f2f60afa0 100644 --- a/frontend/src/app/features/apps/pages/app.component.html +++ b/frontend/src/app/features/apps/pages/app.component.html @@ -1,10 +1,10 @@ <div class="card card-href card-app" [routerLink]="['/app', app.name]"> <div class="card-body" sqxTourStep="app"> - <div class="row g-0"> + <div class="row g-4"> <div class="col-auto card-left"><sqx-avatar [identifier]="app.name" [image]="app.image" /></div> <div class="col card-right"> - <h3 class="card-title">{{ app.displayName }}</h3> + <h3 class="card-title mb-1">{{ app.displayName }}</h3> <div class="card-text card-links truncate"> <a [routerLink]="['/app', app.name]" sqxStopClick>{{ "common.edit" | sqxTranslate }}</a> diff --git a/frontend/src/app/features/apps/pages/app.component.scss b/frontend/src/app/features/apps/pages/app.component.scss index 9a9e5321d..347868d6e 100644 --- a/frontend/src/app/features/apps/pages/app.component.scss +++ b/frontend/src/app/features/apps/pages/app.component.scss @@ -5,10 +5,73 @@ @include absolute(1rem, 1rem); } -.card-body { - position: relative; -} +.card { + @include hover-visible('.deeplinks', inline); + + &-title { + padding-right: 2rem; + } + + &-body { + position: relative; + } + + &-right { + overflow: hidden; + } + + &-image { + text-align: center; + + img { + height: 6rem; + } + } + + &-text { + font-size: $font-small; + + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &-title { + @include truncate; + color: $color-title; + margin-bottom: 0; + margin-top: 0; + } + + &-template { + .card-body { + min-height: 15.5rem; + } + + .card-title { + margin-bottom: .75rem; + margin-top: 1rem; + } + } + + &-href { + cursor: pointer; + + &:active, + &:focus { + text-decoration: none; + } + + &:focus { + outline: none; + } -.card-title { - padding-right: 2rem; + &:hover { + @include box-shadow-outer(0, 3px, 16px, .2); + } + } } \ No newline at end of file diff --git a/frontend/src/app/features/apps/pages/apps-page.component.html b/frontend/src/app/features/apps/pages/apps-page.component.html index 91049fb23..e54e63769 100644 --- a/frontend/src/app/features/apps/pages/apps-page.component.html +++ b/frontend/src/app/features/apps/pages/apps-page.component.html @@ -1,67 +1,65 @@ <sqx-title message="i18n:apps.listPageTitle" /> -@if (authState.userChanges | async; as user) { - <div class="panel-container page"> - <div class="apps-section"> - <h1 class="apps-title">{{ "apps.welcomeTitle" | sqxTranslate: { user: user.displayName } }}</h1> - - <div class="subtext">{{ "apps.welcomeSubtitle" | sqxTranslate }}</div> - </div> - - @if (groupedApps | async; as groups) { - <div class="apps-section" sqxTourStep="allApps"> - @for (group of groups; track trackByGroup($index, group)) { - <div class="team"> - @if (group.team) { - <div class="team-header"><sqx-team (leave)="leaveTeam($event)" [team]="group.team" /></div> - } - <div class="team-body" [class.padded]="group.team"> - @for (app of group.apps; track app.id) { - <sqx-app [app]="app" (leave)="leaveApp($event)" /> - } @empty { - <small class="team-empty"> {{ "teams.empty" | sqxTranslate }} </small> - } - </div> - </div> - } @empty { - <div class="empty"> - <h3 class="empty-headline">{{ "apps.empty" | sqxTranslate }}</h3> - </div> - } - </div> - } - - @if ((uiState.settings | async)?.canCreateApps) { +@if (authState.userChanges | async; as user) { + <div class="panel-container page flex-grow d-flex flex-grow flex-column justify-content-between" style="flex-grow: 1"> + <div class="apps"> <div class="apps-section"> - <div class="card card-template card-href" (click)="createNewApp()" data-testid="new-app" sqxTourStep="addApp"> - <div class="card-body"> - <div class="card-image"><img src="./images/add-app.svg" /></div> + <div class="row align-items-center"> + <div class="col"> + <h1 class="apps-title">{{ "apps.welcomeTitle" | sqxTranslate: { user: user.displayName } }}</h1> - <h3 class="card-title">{{ "apps.createBlankApp" | sqxTranslate }}</h3> - <sqx-form-hint> {{ "apps.createBlankAppDescription" | sqxTranslate }} </sqx-form-hint> + <div class="subtext">{{ "apps.welcomeSubtitle" | sqxTranslate }}</div> </div> + + @if ((uiState.settings | async)?.canCreateApps) { + <div class="col-auto"> + <button class="btn btn-block btn-success" (click)="addAppDialog.show()" data-testid="new-app" sqxTourStep="addApp" type="button"> + <i class="icon-plus"></i> {{ "apps.appsButtonCreate" | sqxTranslate }} + </button> + </div> + } </div> + </div> - @for (template of templates | async; track template) { - <div class="card card-template card-href" (click)="createNewApp(template)"> - <div class="card-body"> - <div class="card-image"><img src="./images/add-template.svg" /></div> + @if (groupedApps | async; as groups) { + <div class="apps-section" sqxTourStep="allApps"> + @for (group of groups; track trackByGroup($index, group)) { + <div class="team"> + @if (group.team) { + <div class="team-header"><sqx-team (leave)="leaveTeam($event)" [team]="group.team" /></div> + } - <h3 class="card-title">{{ template.title }}</h3> - <sqx-form-hint> {{ template.description }} </sqx-form-hint> + <div class="team-body row g-2" [class.padded]="group.team"> + @for (app of group.apps; track app.id) { + <div class="col-12 col-md-6 col-lg-4"> + <sqx-app [app]="app" (leave)="leaveApp($event)" /> + </div> + } @empty { + <small class="team-empty"> {{ "teams.empty" | sqxTranslate }} </small> + } + </div> </div> - </div> - } - </div> - } + } @empty { + <div class="empty"> + <h3 class="empty-headline">{{ "apps.empty" | sqxTranslate }}</h3> + </div> + } + </div> + } + </div> - @if (info) { + @if (generalInfo) { <div class="apps-section"> - <small class="info">{{ info }}</small> + <small class="info">{{ generalInfo }}</small> </div> } </div> } -<sqx-app-form (dialogClose)="addAppDialog.hide()" *sqxModal="addAppDialog" [template]="addAppTemplate" /> + <sqx-onboarding-dialog (dialogClose)="onboardingDialog.hide()" *sqxModal="onboardingDialog" /> + +@if (starters | async; as templates) { + <sqx-app-form (dialogClose)="addAppDialog.hide()" *sqxModal="addAppDialog" [template]="addAppTemplate" [templates]="templates" /> +} + <sqx-news-dialog (dialogClose)="newsDialog.hide()" [features]="newsFeatures!" *sqxModal="newsDialog" /> diff --git a/frontend/src/app/features/apps/pages/apps-page.component.scss b/frontend/src/app/features/apps/pages/apps-page.component.scss index e8ebbddd0..9b723e41a 100644 --- a/frontend/src/app/features/apps/pages/apps-page.component.scss +++ b/frontend/src/app/features/apps/pages/apps-page.component.scss @@ -2,7 +2,15 @@ @import 'vars'; .apps-section { - padding: 2rem 1.25rem 0 $size-sidebar-width + .25rem; + margin: 0 $size-sidebar-width; + max-width: 1080px; + padding-top: 2rem; + padding-left: 16px; + padding-right: 16px; + + &:last-child { + margin-bottom: 1rem; + } } .page { @@ -11,84 +19,6 @@ overflow-y: auto; } -:host ::ng-deep { - .card { - @include hover-visible('.deeplinks', inline); - display: inline-block; - margin-bottom: 1rem; - margin-right: 1rem; - vertical-align: top; - width: 20rem; - - &-links { - margin-top: .5rem; - } - - &-left { - padding-right: .75rem; - } - - &-right { - overflow: hidden; - } - - &-image { - text-align: center; - - img { - height: 6rem; - } - } - - &-text { - font-size: $font-small; - - a { - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - } - - &-title { - @include truncate; - color: $color-title; - margin-bottom: 0; - margin-top: 0; - } - - &-template { - .card-body { - min-height: 15.5rem; - } - - .card-title { - margin-bottom: .75rem; - margin-top: 1rem; - } - } - - &-href { - cursor: pointer; - - &:active, - &:focus { - text-decoration: none; - } - - &:focus { - outline: none; - } - - &:hover { - @include box-shadow-outer(0, 3px, 16px, .2); - } - } - } -} - .team { margin-top: 1rem; diff --git a/frontend/src/app/features/apps/pages/apps-page.component.ts b/frontend/src/app/features/apps/pages/apps-page.component.ts index f85f81bc2..b941973de 100644 --- a/frontend/src/app/features/apps/pages/apps-page.component.ts +++ b/frontend/src/app/features/apps/pages/apps-page.component.ts @@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; -import { AppDto, AppFormComponent, AppsState, AuthService, DialogModel, FeatureDto, FormHintComponent, LocalStoreService, ModalDirective, NewsService, Settings, TeamDto, TeamsState, TemplateDto, TemplatesState, TitleComponent, TourState, TourStepDirective, TranslatePipe, UIOptions, UIState } from '@app/shared'; +import { AppDto, AppFormComponent, AppsState, AuthService, DialogModel, FeatureDto, LocalStoreService, ModalDirective, NewsService, Settings, TeamDto, TeamsState, TemplateDto, TemplatesState, TitleComponent, TourState, TourStepDirective, TranslatePipe, UIOptions, UIState } from '@app/shared'; import { AppComponent } from './app.component'; import { NewsDialogComponent } from './news-dialog.component'; import { OnboardingDialogComponent } from './onboarding-dialog.component'; @@ -25,7 +25,6 @@ type GroupedApps = { team?: TeamDto; apps: AppDto[] }; AppComponent, AppFormComponent, AsyncPipe, - FormHintComponent, ModalDirective, NewsDialogComponent, OnboardingDialogComponent, @@ -44,11 +43,9 @@ export class AppsPageComponent implements OnInit { public newsFeatures?: ReadonlyArray<FeatureDto>; public newsDialog = new DialogModel(); - public info = ''; + public generalInfo = ''; - public templates = - this.templatesState.templates.pipe( - map(x => x.filter(t => t.isStarter))); + public starters = this.templatesState.starters; public groupedApps = combineLatest([ @@ -86,7 +83,7 @@ export class AppsPageComponent implements OnInit { private readonly uiOptions: UIOptions, ) { if (uiOptions.value.showInfo) { - this.info = uiOptions.value.info; + this.generalInfo = uiOptions.value.info; } } diff --git a/frontend/src/app/features/settings/pages/templates/template.component.html b/frontend/src/app/features/settings/pages/templates/template.component.html deleted file mode 100644 index 3bbd98010..000000000 --- a/frontend/src/app/features/settings/pages/templates/template.component.html +++ /dev/null @@ -1,31 +0,0 @@ -<div class="table-items-row table-items-row-expandable"> - <div class="table-items-row-summary row gx-2 align-items-center"> - <div class="col"> - {{ template.title }} <sqx-form-hint class="truncate">{{ template.description }}</sqx-form-hint> - </div> - - <div class="col-auto"> - <div class="float-end"> - <button class="btn btn-outline-secondary btn-expand me-1" [class.expanded]="isExpanded" (click)="toggleExpanded()" type="button"> - <i class="icon-download"></i> - </button> - </div> - </div> - </div> - - @if (isExpanded) { - <div class="table-items-row-details"> - <div class="table-items-row-details-tabs clearfix"> - <h4>{{ "common.details" | sqxTranslate }}</h4> - </div> - - <div class="table-items-row-details-tab"> - @if (details | async; as loadedDetails) { - <div class="help" [sqxMarkdown]="loadedDetails"></div> - } @else { - <sqx-loader /> - } - </div> - </div> - } -</div> diff --git a/frontend/src/app/features/settings/pages/templates/template.component.scss b/frontend/src/app/features/settings/pages/templates/template.component.scss deleted file mode 100644 index 86214aa61..000000000 --- a/frontend/src/app/features/settings/pages/templates/template.component.scss +++ /dev/null @@ -1,17 +0,0 @@ -@import 'mixins'; -@import 'vars'; - -h4 { - font-size: 1rem; - font-weight: 500; - margin: 0; - margin-top: .5rem; -} - -.help { - ::ng-deep { - h1 { - display: none; - } - } -} \ No newline at end of file diff --git a/frontend/src/app/features/settings/pages/templates/template.component.ts b/frontend/src/app/features/settings/pages/templates/template.component.ts deleted file mode 100644 index c7ca2e7f1..000000000 --- a/frontend/src/app/features/settings/pages/templates/template.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -import { AsyncPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { map, Observable, shareReplay } from 'rxjs'; -import { AppsState, ClientsState, FormHintComponent, LoaderComponent, MarkdownDirective, TemplateDetailsDto, TemplateDto, TemplatesService, TranslatePipe } from '@app/shared'; - -@Component({ - selector: 'sqx-template', - styleUrls: ['./template.component.scss'], - templateUrl: './template.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - AsyncPipe, - FormHintComponent, - LoaderComponent, - MarkdownDirective, - TranslatePipe, - ], -}) -export class TemplateComponent { - @Input({ required: true }) - public template!: TemplateDto; - - public isExpanded = false; - - public details?: Observable<string>; - - constructor( - private readonly clientsState: ClientsState, - private readonly appsState: AppsState, - private readonly templatesService: TemplatesService, - ) { - } - - public ngOnChanges() { - this.details = this.templatesService.getTemplate(this.template).pipe(map(x => this.buildDetails(x)), shareReplay(1)); - } - - public toggleExpanded() { - this.isExpanded = !this.isExpanded; - } - - private buildDetails(dto: TemplateDetailsDto) { - const app = this.appsState.appName; - - let details = dto.details.replace(/<APP>/g, app); - - const client = this.clientsState.snapshot.clients[0]; - - if (client) { - const clientId = `${app}:${client.id}`; - - details = details.replace(/\<CLIENT_ID>/g, clientId); - details = details.replace(/\<CLIENT_SECRET>/g, client.secret); - } - - return details; - } -} diff --git a/frontend/src/app/features/settings/pages/templates/templates-page.component.html b/frontend/src/app/features/settings/pages/templates/templates-page.component.html deleted file mode 100644 index e5b372b0a..000000000 --- a/frontend/src/app/features/settings/pages/templates/templates-page.component.html +++ /dev/null @@ -1,37 +0,0 @@ -<sqx-title message="i18n:common.templates" /> -<sqx-layout innerWidth="50" layout="main" titleIcon="download" titleText="i18n:common.templates"> - <ng-container menu> - <button class="btn btn-text-secondary me-2" (click)="reload()" shortcut="CTRL + B" title="i18n:templates.refreshTooltip" type="button"> - <i class="icon-reset"></i> {{ "common.refresh" | sqxTranslate }} - </button> - </ng-container> - <ng-container> - <sqx-list-view innerWidth="50rem" [isLoading]="templatesState.isLoading | async"> - <sqx-form-alert light="true"> - <div inline="true" [sqxMarkdown]="'templates.cliHint' | sqxTranslate"></div> - </sqx-form-alert> - @if ((templatesState.isLoaded | async) && (templatesState.templates | async); as templates) { - @for (template of templates; track template.name) { - <sqx-template [template]="template" /> - } - } - </sqx-list-view> - </ng-container> - <ng-template sidebarMenu> - <div class="panel-nav"> - <a - class="panel-link" - attr.aria-label="{{ 'common.help' | sqxTranslate }}" - queryParamsHandling="preserve" - replaceUrl="true" - routerLink="help" - routerLinkActive="active" - sqxTourStep="help" - title="i18n:common.help" - titlePosition="left"> - <i class="icon-help2"></i> - </a> - </div> - </ng-template> -</sqx-layout> -<router-outlet /> diff --git a/frontend/src/app/features/settings/pages/templates/templates-page.component.scss b/frontend/src/app/features/settings/pages/templates/templates-page.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/features/settings/pages/templates/templates-page.component.ts b/frontend/src/app/features/settings/pages/templates/templates-page.component.ts deleted file mode 100644 index dbb842257..000000000 --- a/frontend/src/app/features/settings/pages/templates/templates-page.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -import { AsyncPipe } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; -import { ClientsState, FormAlertComponent, LayoutComponent, ListViewComponent, MarkdownDirective, ShortcutDirective, SidebarMenuDirective, TemplatesState, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/shared'; -import { TemplateComponent } from './template.component'; - -@Component({ - selector: 'sqx-templates-page', - styleUrls: ['./templates-page.component.scss'], - templateUrl: './templates-page.component.html', - imports: [ - AsyncPipe, - FormAlertComponent, - LayoutComponent, - ListViewComponent, - MarkdownDirective, - RouterLink, - RouterLinkActive, - RouterOutlet, - ShortcutDirective, - SidebarMenuDirective, - TemplateComponent, - TitleComponent, - TooltipDirective, - TourStepDirective, - TranslatePipe, - ], -}) -export class TemplatesPageComponent implements OnInit { - constructor( - public readonly clientsState: ClientsState, - public readonly templatesState: TemplatesState, - ) { - } - - public ngOnInit() { - this.clientsState.load(); - - this.templatesState.load(); - } - - public reload() { - this.templatesState.load(true); - } -} diff --git a/frontend/src/app/features/settings/routes.ts b/frontend/src/app/features/settings/routes.ts index d70d5233a..7dfb6a213 100644 --- a/frontend/src/app/features/settings/routes.ts +++ b/frontend/src/app/features/settings/routes.ts @@ -16,7 +16,6 @@ import { MorePageComponent } from './pages/more/more-page.component'; import { PlansPageComponent } from './pages/plans/plans-page.component'; import { RolesPageComponent } from './pages/roles/roles-page.component'; import { SettingsPageComponent } from './pages/settings/settings-page.component'; -import { TemplatesPageComponent } from './pages/templates/templates-page.component'; import { WorkflowsPageComponent } from './pages/workflows/workflows-page.component'; import { SettingsAreaComponent } from './settings-area.component'; @@ -142,19 +141,6 @@ export const SETTINGS_ROUTES: Routes = [ }, ], }, - { - path: 'templates', - component: TemplatesPageComponent, - children: [ - { - path: 'help', - component: HelpComponent, - data: { - helpPage: '05-integrated/templates', - }, - }, - ], - }, { path: 'plans', component: PlansPageComponent, diff --git a/frontend/src/app/features/settings/settings-menu.component.html b/frontend/src/app/features/settings/settings-menu.component.html index c9f266646..65310082f 100644 --- a/frontend/src/app/features/settings/settings-menu.component.html +++ b/frontend/src/app/features/settings/settings-menu.component.html @@ -53,10 +53,6 @@ <li class="nav-item nav-heading">{{ "common.more" | sqxTranslate }}</li> - <li class="nav-item" sqxTourStep="templates"> - <a class="nav-link" routerLink="templates" routerLinkActive="active"> <i class="icon-download"></i> {{ "common.templates" | sqxTranslate }} </a> - </li> - @if (app.canReadJobs) { <li class="nav-item" sqxTourStep="jobs"> <a class="nav-link" routerLink="jobs" routerLinkActive="active"> <i class="icon-backups"></i> {{ "common.jobsBackups" | sqxTranslate }} </a> diff --git a/frontend/src/app/shared/components/app-form.component.html b/frontend/src/app/shared/components/app-form.component.html index 88d461169..55365dcc7 100644 --- a/frontend/src/app/shared/components/app-form.component.html +++ b/frontend/src/app/shared/components/app-form.component.html @@ -1,5 +1,5 @@ <form [formGroup]="createForm.form" (ngSubmit)="createApp()"> - <sqx-modal-dialog (dialogClose)="emitClose()" tourId="appForm"> + <sqx-modal-dialog (dialogClose)="emitClose()" flexBody="true" [size]="templates.length > 0 ? 'lg' : 'md'" tourId="appForm"> <ng-container title> @if (template) { {{ "apps.createWithTemplate" | sqxTranslate: { template: template.title } }} @@ -8,18 +8,74 @@ } </ng-container> <ng-container content> - <sqx-form-error [error]="createForm.error | async" /> - <div class="form-group mt-2"> - <label for="appName"> - {{ "common.name" | sqxTranslate }} <small class="hint">({{ "common.requiredHint" | sqxTranslate }})</small> - </label> - <sqx-control-errors for="name" /> - <input class="form-control" id="name" autocomplete="off" formControlName="name" sqxFocusOnInit sqxTransformInput="LowerCase" /> - <sqx-form-hint> {{ "apps.appNameHint" | sqxTranslate }} </sqx-form-hint> - </div> + <div class="row g-0"> + <div class="col col-left"> + <div class="card-body"> + <sqx-form-error [error]="createForm.error | async" /> + <div class="form-group mt-2"> + <label for="appName"> + {{ "common.name" | sqxTranslate }} <small class="hint">({{ "common.requiredHint" | sqxTranslate }})</small> + </label> + <sqx-control-errors for="name" /> + <input class="form-control" id="name" autocomplete="off" formControlName="name" sqxFocusOnInit sqxTransformInput="LowerCase" /> + <sqx-form-hint> {{ "apps.appNameHint" | sqxTranslate }} </sqx-form-hint> + </div> + + <div class="form-group"> + <sqx-form-alert marginBottom="0" marginTop="2"> {{ "apps.appNameWarning" | sqxTranslate }} </sqx-form-alert> + </div> + + @if (templates.length > 0) { + <h4 class="mt-6">{{ "apps.selectAppTemplate" | sqxTranslate }}</h4> + + <div class="grid" style="--bs-gap: 0.5rem 0.5rem"> + <div + class="g-col-6 g-col-xl-4 card card-template card-href" + [class.border-primary]="!template" + (click)="selectTemplate()" + data-testid="new-app" + sqxTourStep="addApp"> + <div class="card-body"> + <div class="card-image"><img src="./images/add-app.svg" /></div> + + <h5 class="card-title mt-3">{{ "apps.createBlankApp" | sqxTranslate }}</h5> + <sqx-form-hint> {{ "apps.createBlankAppDescription" | sqxTranslate }} </sqx-form-hint> + </div> + </div> + + @for (availableTemplate of templates; track availableTemplate) { + <div + class="g-col-6 g-col-xl-4 card card-template card-href" + [class.border-primary]="availableTemplate === template" + (click)="selectTemplate(availableTemplate)"> + <div class="card-body"> + <div class="card-image"> + @if (availableTemplate.logo) { + <img [src]="availableTemplate.logo" /> + } @else { + <img src="./images/add-template.svg" /> + } + </div> + + <h5 class="card-title mt-3">{{ availableTemplate.title }}</h5> + <sqx-form-hint> {{ availableTemplate.description }} </sqx-form-hint> + </div> + </div> + } + </div> + } + </div> + </div> + @if (templates.length > 0) { + <div class="col-info d-none d-lg-block"> + <sqx-form-hint> {{ "apps.appExplanation" | sqxTranslate }} </sqx-form-hint> - <div class="form-group"> - <sqx-form-alert marginBottom="0" marginTop="2"> {{ "apps.appNameWarning" | sqxTranslate }} </sqx-form-alert> + @if (template) { + <h4 class="mt-4">{{ "apps.template" | sqxTranslate }}</h4> + <div class="help" inline="false" [sqxMarkdown]="template.details"></div> + } + </div> + } </div> </ng-container> <ng-container footer> diff --git a/frontend/src/app/shared/components/app-form.component.scss b/frontend/src/app/shared/components/app-form.component.scss index 2742d895e..657e03841 100644 --- a/frontend/src/app/shared/components/app-form.component.scss +++ b/frontend/src/app/shared/components/app-form.component.scss @@ -1,2 +1,31 @@ +@use 'sass:color'; @import 'mixins'; -@import 'vars'; \ No newline at end of file +@import 'vars'; + +.card-image { + width: 30%; +} + +.card-title { + margin: .25rem 0; +} + +.col-info { + background-color: color.adjust($color-background, $lightness: 2%); + border-radius: 0; + border-left: 1px solid $color-border; + padding: 1.5rem 2rem; + width: 18rem; +} + +.col-left { + padding: 1.5rem 2rem; +} + +.help { + ::ng-deep { + h1 { + display: none; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/app-form.component.ts b/frontend/src/app/shared/components/app-form.component.ts index ae700937f..16b7fc365 100644 --- a/frontend/src/app/shared/components/app-form.component.ts +++ b/frontend/src/app/shared/components/app-form.component.ts @@ -8,7 +8,7 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ApiUrlConfig, ControlErrorsComponent, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, ModalDialogComponent, TooltipDirective, TransformInputDirective, TranslatePipe } from '@app/framework'; +import { ApiUrlConfig, ControlErrorsComponent, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, MarkdownDirective, ModalDialogComponent, TooltipDirective, TransformInputDirective, TranslatePipe } from '@app/framework'; import { AppsState, CreateAppDto, CreateAppForm, TemplateDto } from '@app/shared/internal'; @Component({ @@ -24,6 +24,7 @@ import { AppsState, CreateAppDto, CreateAppForm, TemplateDto } from '@app/shared FormErrorComponent, FormHintComponent, FormsModule, + MarkdownDirective, ModalDialogComponent, ReactiveFormsModule, TooltipDirective, @@ -38,6 +39,9 @@ export class AppFormComponent { @Input() public template?: TemplateDto; + @Input({ required: true }) + public templates: TemplateDto[] = []; + public createForm = new CreateAppForm(); constructor( @@ -50,6 +54,10 @@ export class AppFormComponent { this.dialogClose.emit(); } + public selectTemplate(template?: TemplateDto) { + this.template = template; + } + public createApp() { const value = this.createForm.submit(); if (!value) { diff --git a/frontend/src/app/shared/model/generated.ts b/frontend/src/app/shared/model/generated.ts index 1df88e61a..c6050ab6b 100644 --- a/frontend/src/app/shared/model/generated.ts +++ b/frontend/src/app/shared/model/generated.ts @@ -1005,8 +1005,12 @@ export class TemplateDto extends ResourceDto implements ITemplateDto { readonly title!: string; /** The description of the template. */ readonly description!: string; + /** The details of the template. */ + readonly details!: string; /** True, if the template is a starter. */ readonly isStarter!: boolean; + /** The optional logo. */ + readonly logo?: string | undefined; constructor(data?: ITemplateDto) { super(data); @@ -1017,7 +1021,9 @@ export class TemplateDto extends ResourceDto implements ITemplateDto { (<any>this).name = _data["name"]; (<any>this).title = _data["title"]; (<any>this).description = _data["description"]; + (<any>this).details = _data["details"]; (<any>this).isStarter = _data["isStarter"]; + (<any>this).logo = _data["logo"]; this.cleanup(this); return this; } @@ -1033,7 +1039,9 @@ export class TemplateDto extends ResourceDto implements ITemplateDto { data["name"] = this.name; data["title"] = this.title; data["description"] = this.description; + data["details"] = this.details; data["isStarter"] = this.isStarter; + data["logo"] = this.logo; super.toJSON(data); this.cleanup(data); return data; @@ -1047,8 +1055,12 @@ export interface ITemplateDto extends IResourceDto { readonly title: string; /** The description of the template. */ readonly description: string; + /** The details of the template. */ + readonly details: string; /** True, if the template is a starter. */ readonly isStarter: boolean; + /** The optional logo. */ + readonly logo?: string | undefined; } export class TemplateDetailsDto extends ResourceDto implements ITemplateDetailsDto { diff --git a/frontend/src/app/shared/services/templates.service.spec.ts b/frontend/src/app/shared/services/templates.service.spec.ts index 7f07385ce..564709d0d 100644 --- a/frontend/src/app/shared/services/templates.service.spec.ts +++ b/frontend/src/app/shared/services/templates.service.spec.ts @@ -36,7 +36,7 @@ describe('TemplatesService', () => { templates = result; }); - const req = httpMock.expectOne('http://service/p/api/templates'); + const req = httpMock.expectOne('http://service/p/api/templates?includeDetails=true'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); @@ -88,6 +88,7 @@ describe('TemplatesService', () => { return { name: `name${key}`, title: `Title ${key}`, + details: '', description: `Description ${key}`, isStarter: id % 2 === 0, _links: { @@ -114,6 +115,7 @@ export function createTemplate(id: number, suffix = '') { return new TemplateDto({ name: `name${key}`, title: `Title ${key}`, + details: '', description: `Description ${key}`, isStarter: id % 2 === 0, _links: { diff --git a/frontend/src/app/shared/services/templates.service.ts b/frontend/src/app/shared/services/templates.service.ts index 8301992a6..ba757b18f 100644 --- a/frontend/src/app/shared/services/templates.service.ts +++ b/frontend/src/app/shared/services/templates.service.ts @@ -23,7 +23,7 @@ export class TemplatesService { } public getTemplates(): Observable<TemplatesDto> { - const url = this.apiUrl.buildUrl('api/templates'); + const url = this.apiUrl.buildUrl('api/templates?includeDetails=true'); return this.http.get<any>(url).pipe( map(body => { diff --git a/frontend/src/app/shared/state/template.state.spec.ts b/frontend/src/app/shared/state/template.state.spec.ts index df87a8b2f..dd9c89bf1 100644 --- a/frontend/src/app/shared/state/template.state.spec.ts +++ b/frontend/src/app/shared/state/template.state.spec.ts @@ -7,7 +7,7 @@ import { of, onErrorResumeNextWith, throwError } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; -import { DialogService, TemplatesDto, TemplatesService, TemplatesState } from '@app/shared/internal'; +import { DialogService, TemplateDto, TemplatesDto, TemplatesService, TemplatesState } from '@app/shared/internal'; import { createTemplate } from '../services/templates.service.spec'; describe('TemplatesState', () => { @@ -43,6 +43,20 @@ describe('TemplatesState', () => { dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); + it('should provide starters', () => { + templatesService.setup(x => x.getTemplates()) + .returns(() => of(new TemplatesDto({ items: [template1, template2], _links: {} }))).verifiable(); + + templatesState.load().subscribe(); + + let starters: TemplateDto[] = []; + templatesState.starters.subscribe(x => { + starters = x; + }); + + expect(starters).toEqual([template1]); + }); + it('should reset loading state if loading failed', () => { templatesService.setup(x => x.getTemplates()) .returns(() => throwError(() => 'Service Error')); diff --git a/frontend/src/app/shared/state/templates.state.ts b/frontend/src/app/shared/state/templates.state.ts index 5620fa8aa..88e3e7a19 100644 --- a/frontend/src/app/shared/state/templates.state.ts +++ b/frontend/src/app/shared/state/templates.state.ts @@ -27,6 +27,9 @@ export class TemplatesState extends State<Snapshot> { public templates = this.project(x => x.templates); + public starters = + this.project(x => x.templates.filter(x => x.isStarter)); + public isLoaded = this.project(x => x.isLoaded === true); diff --git a/frontend/src/app/shell/pages/internal/apps-menu.component.html b/frontend/src/app/shell/pages/internal/apps-menu.component.html index 81f3df15d..db7882b59 100644 --- a/frontend/src/app/shell/pages/internal/apps-menu.component.html +++ b/frontend/src/app/shell/pages/internal/apps-menu.component.html @@ -92,5 +92,9 @@ </nav> } </ul> -<sqx-app-form (dialogClose)="addAppDialog.hide()" *sqxModal="addAppDialog" /> + +@if (starters | async; as templates) { + <sqx-app-form (dialogClose)="addAppDialog.hide()" *sqxModal="addAppDialog" [templates]="templates" /> +} + <sqx-team-form (dialogClose)="addTeamDialog.hide()" *sqxModal="addTeamDialog" /> diff --git a/frontend/src/app/shell/pages/internal/apps-menu.component.ts b/frontend/src/app/shell/pages/internal/apps-menu.component.ts index ce8224d2e..b54b7169d 100644 --- a/frontend/src/app/shell/pages/internal/apps-menu.component.ts +++ b/frontend/src/app/shell/pages/internal/apps-menu.component.ts @@ -10,7 +10,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { AppFormComponent, AppsState, DialogModel, DropdownMenuComponent, ModalDirective, ModalModel, ModalPlacementDirective, TeamFormComponent, TeamsState, Title, TitleService, TranslatePipe, UIState } from '@app/shared'; +import { AppFormComponent, AppsState, DialogModel, DropdownMenuComponent, ModalDirective, ModalModel, ModalPlacementDirective, TeamFormComponent, TeamsState, TemplatesState, Title, TitleService, TranslatePipe, UIState } from '@app/shared'; @Component({ selector: 'sqx-apps-menu', @@ -36,11 +36,14 @@ export class AppsMenuComponent { public appsMenu = new ModalModel(); public appPath: Observable<ReadonlyArray<Title>>; + public starters = this.templatesState.starters; + constructor(titleService: TitleService, public readonly appsState: AppsState, public readonly route: ActivatedRoute, public readonly teamsState: TeamsState, public readonly uiState: UIState, + private readonly templatesState: TemplatesState, ) { this.appPath = titleService.pathChanges.pipe(map(x => x.slice(1))); } diff --git a/frontend/src/app/theme/_bootstrap-vars.scss b/frontend/src/app/theme/_bootstrap-vars.scss index 28461a77e..eda75b79b 100644 --- a/frontend/src/app/theme/_bootstrap-vars.scss +++ b/frontend/src/app/theme/_bootstrap-vars.scss @@ -3,6 +3,8 @@ /* stylelint-disable */ +$enable-cssgrid: true; + // // GENERAL // diff --git a/frontend/src/app/theme/_common.scss b/frontend/src/app/theme/_common.scss index fa4814586..54dc32589 100644 --- a/frontend/src/app/theme/_common.scss +++ b/frontend/src/app/theme/_common.scss @@ -161,6 +161,10 @@ hr { } } + ul { + margin-top: .25rem; + } + sqx-form-hint { small { font-size: 100%; diff --git a/frontend/src/app/theme/_forms.scss b/frontend/src/app/theme/_forms.scss index ff44125c8..69951c09d 100644 --- a/frontend/src/app/theme/_forms.scss +++ b/frontend/src/app/theme/_forms.scss @@ -43,13 +43,13 @@ // Small triangle under the error tooltip with the border trick. &::after { - @include absolute(null, null, -.75rem, .625rem); - @include caret-bottom($color-theme-error, .4rem); + @include absolute(null, null, -.5rem, .25rem); + @include caret-bottom($color-theme-error, .3rem); } // The tooltip rectangle itself. & { - @include absolute(null, null, .4rem, 0); + @include absolute(null, null, .2rem, 0); background: $color-theme-error; border: 0; border-radius: .5 * $border-radius; @@ -243,11 +243,16 @@ & ~ .hidden { margin-bottom: 0; } + + & label { + margin-bottom: .125rem;; + } } .col-form-label { font-size: 90%; } + label { & { font-size: 90%; diff --git a/tools/e2e/tests/pages/apps.ts b/tools/e2e/tests/pages/apps.ts index af379ccbe..1e7586fc4 100644 --- a/tools/e2e/tests/pages/apps.ts +++ b/tools/e2e/tests/pages/apps.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { expect, Page } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; export class AppsPage { constructor(private readonly page: Page) {} @@ -21,7 +21,7 @@ export class AppsPage { public async openAppDialog() { await this.page.getByTestId('new-app').click(); - return new AppDialog(this.page); + return new AppDialog(this.page, this.page.getByTestId('dialog')); } public async createNewApp(appName: string) { @@ -36,13 +36,16 @@ export class AppsPage { } class AppDialog { - constructor(private readonly page: Page) {} + constructor(private readonly page: Page, + public readonly root: Locator, + ) { + } public async enterName(name: string) { - await this.page.locator('#name').fill(name); + await this.root.locator('#name').fill(name); } public async save() { - await this.page.getByRole('button', { name: 'Create' }).click(); + await this.root.getByRole('button', { name: 'Create' }).click(); } } \ No newline at end of file