Added PowerPoint templates to the Slide Assistant (#1055)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Sabrina-devopsandThorsten Sommer authored and GitHub committed 2026-10-25 18:52:39 +01:00
1 parent 8c5cfa78b6
commit 6eb23ebbcb
136 files changed
+16535 -522

No files matched your search

@@ -0,0 +1,15 @@
<MudExpansionPanels Dense="@true" Elevation="0" Class="@this.Class">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD" Expanded="@this.Expanded" ExpandedChanged="@this.ExpandedChanged">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@this.HeaderIcon" Color="@this.IconColor" Class="mr-3"/>
<MudText Typo="Typo.button">
@this.HeaderText
</MudText>
</div>
</TitleContent>
<ChildContent>
@this.ChildContent
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// A single, dense panel which hides content most users do not need, such as advanced options
/// or the source code of a generated plugin.
/// </summary>
/// <remarks>
/// Unlike the ExpansionPanel component, it brings its own panel list, so it stands anywhere in a
/// form. The header text comes from the caller, which keeps the translation with the caller as
/// well.
/// </remarks>
public partial class CompactExpansionPanel : ComponentBase
{
[Parameter]
public string HeaderText { get; set; } = string.Empty;
[Parameter]
public string HeaderIcon { get; set; } = Icons.Material.Filled.Tune;
[Parameter]
public Color IconColor { get; set; } = Color.Inherit;
/// <summary>
/// Class names for the surrounding panel list, separated by space, e.g., for its margins.
/// </summary>
[Parameter]
public string Class { get; set; } = string.Empty;
[Parameter]
public bool Expanded { get; set; }
[Parameter]
public EventCallback<bool> ExpandedChanged { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -1,27 +1,30 @@
@inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<PathDropZone Frameless="@true" Disabled="@(() => this.IsDisabled)" OnPathsDropped="@this.PathsDropped" Context="isDropTarget">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Style="@(isDropTarget ? PathDropZone.FIELD_HIGHLIGHT_STYLE : string.Empty)">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Placeholder="@(this.IsDisabled ? string.Empty : T("You can also drag & drop the folder here."))"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@(isDropTarget ? Color.Primary : this.IconColor)"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isDirectoryDialogOpen)"
Class="mb-1"
OnClick="@this.OpenDirectoryDialog">
@T("Choose Directory")
</MudButton>
</MudStack>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isDirectoryDialogOpen)"
Class="mb-1"
OnClick="@this.OpenDirectoryDialog">
@T("Choose Directory")
</MudButton>
</MudStack>
</PathDropZone>
@@ -41,6 +41,9 @@ public partial class ConfigurationDirectory : ConfigurationBaseCore
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private ILogger<ConfigurationDirectory> Logger { get; init; } = null!;
private string internalText = string.Empty;
private bool isDirectoryDialogOpen;
@@ -105,6 +108,26 @@ public partial class ConfigurationDirectory : ConfigurationBaseCore
}
}
/// <summary>
/// Takes the first dropped path which leads to a folder.
/// </summary>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
var path = DroppedFolder.FirstUsable(paths);
if (path is null)
{
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a directory.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFolder.WarningFor(paths)));
return;
}
this.Logger.LogInformation("The user dropped the directory '{DroppedDirectory}'.", path);
this.timer.Stop();
this.internalText = path;
await this.OptionChanged(path);
}
private async Task OptionChanged(string updatedText)
{
this.TextUpdate(updatedText);
@@ -1,27 +1,30 @@
@inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<PathDropZone Frameless="@true" Disabled="@(() => this.IsDisabled)" OnPathsDropped="@this.PathsDropped" Context="isDropTarget">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Style="@(isDropTarget ? PathDropZone.FIELD_HIGHLIGHT_STYLE : string.Empty)">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Placeholder="@(this.IsDisabled ? string.Empty : T("You can also drag & drop the file here."))"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@(isDropTarget ? Color.Primary : this.IconColor)"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isFileDialogOpen)"
Class="mb-1"
OnClick="@this.OpenFileDialog">
@T("Choose File")
</MudButton>
</MudStack>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isFileDialogOpen)"
Class="mb-1"
OnClick="@this.OpenFileDialog">
@T("Choose File")
</MudButton>
</MudStack>
</PathDropZone>
@@ -48,6 +48,9 @@ public partial class ConfigurationFile : ConfigurationBaseCore
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private ILogger<ConfigurationFile> Logger { get; init; } = null!;
private string internalText = string.Empty;
private bool isFileDialogOpen;
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
@@ -111,6 +114,26 @@ public partial class ConfigurationFile : ConfigurationBaseCore
}
}
/// <summary>
/// Takes the first dropped path which leads to a usable file.
/// </summary>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
var path = DroppedFile.FirstUsable(paths, this.Filter);
if (path is null)
{
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a file.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFile.WarningFor(paths)));
return;
}
this.Logger.LogInformation("The user dropped the file '{DroppedFilePath}'.", path);
this.timer.Stop();
this.internalText = path;
await this.OptionChanged(path);
}
private async Task OptionChanged(string updatedText)
{
this.TextUpdate(updatedText);
@@ -0,0 +1,13 @@
@using AIStudio.Tools.PowerPoint
@foreach (var area in Enum.GetValues<LayoutPurposeArea>())
{
<CompactExpansionPanel HeaderText="@this.HeaderTextOf(area)" HeaderIcon="@IconOf(area)" Class="mb-3 rounded" Expanded="@this.expandedAreas.Contains(area)" ExpandedChanged="@(expanded => this.SetExpanded(area, expanded))">
@if (this.expandedAreas.Contains(area))
{
@foreach (var purpose in Enum.GetValues<LayoutPurpose>().Where(purpose => purpose.Area() == area))
{
@this.ChoiceContent(purpose)
}
}
</CompactExpansionPanel>
}
@@ -0,0 +1,62 @@
using AIStudio.Tools.PowerPoint;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Groups the choices of a layout per kind of slide into one collapsed panel per area, e.g., teaching
/// or science, since more than a hundred choices in one list help nobody.
/// </summary>
/// <remarks>
/// The choice itself comes from the caller: the Slide Assistant uses a plain select, and its
/// settings dialog a configuration select. The header of an area tells how many of its kinds have a
/// layout, so that no choice hides in a collapsed panel.
///
/// The choices of an area render only while its panel is open. A collapsed panel still keeps its
/// content in the page, and all areas together hold more than a hundred selects, which the Slide
/// Assistant would otherwise render again with every update while the LLM plans.
/// </remarks>
public partial class LayoutChoicePanels : ComponentBase
{
/// <summary>
/// Renders the choice of a layout for one kind of slide.
/// </summary>
[Parameter]
public RenderFragment<LayoutPurpose> ChoiceContent { get; set; } = _ => _ => { };
/// <summary>
/// Whether a layout is chosen for a kind of slide.
/// </summary>
[Parameter]
public Func<LayoutPurpose, bool> IsChosen { get; set; } = _ => false;
private readonly HashSet<LayoutPurposeArea> expandedAreas = [];
private string HeaderTextOf(LayoutPurposeArea area)
{
var chosen = Enum.GetValues<LayoutPurpose>().Count(purpose => purpose.Area() == area && this.IsChosen(purpose));
return chosen == 0 ? area.Label() : $"{area.Label()} ({chosen})";
}
private void SetExpanded(LayoutPurposeArea area, bool expanded)
{
if (expanded)
this.expandedAreas.Add(area);
else
this.expandedAreas.Remove(area);
}
private static string IconOf(LayoutPurposeArea area) => area switch
{
LayoutPurposeArea.FRAMING => Icons.Material.Filled.FormatListNumbered,
LayoutPurposeArea.GENERAL => Icons.Material.Filled.ViewQuilt,
LayoutPurposeArea.TEACHING => Icons.Material.Filled.School,
LayoutPurposeArea.BUSINESS => Icons.Material.Filled.BusinessCenter,
LayoutPurposeArea.SCIENCE => Icons.Material.Filled.Science,
LayoutPurposeArea.MEDICINE => Icons.Material.Filled.MedicalServices,
LayoutPurposeArea.LAW => Icons.Material.Filled.Gavel,
_ => Icons.Material.Filled.ViewQuilt,
};
}
@@ -141,6 +141,15 @@ public partial class PathDropZone : MSGComponentBase
[Inject]
private ILogger<PathDropZone> Logger { get; init; } = null!;
/// <summary>
/// Marks a field in a frameless zone while a path hovers over it, e.g., an option in the settings.
/// </summary>
/// <remarks>
/// Such a field sits in the frame of its option already, so it gets no drop frame of its own. An
/// outline takes no room, which keeps the layout still while the cursor moves over the field.
/// </remarks>
public const string FIELD_HIGHLIGHT_STYLE = "outline: 2px dashed var(--mud-palette-primary); outline-offset: 4px; border-radius: 4px;";
private const string FRAME_CLASSES = "relative rounded-lg border-2 border-dashed mud-width-full";
private const string DEFAULT_SPACING_CLASSES = "pa-3 mb-3";
@@ -2,9 +2,6 @@ using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
// This component has a parameter called Directory, which would shadow the file system's Directory class:
using IODirectory = System.IO.Directory;
namespace AIStudio.Components;
public partial class SelectDirectory : MSGComponentBase
@@ -90,35 +87,18 @@ public partial class SelectDirectory : MSGComponentBase
/// <summary>
/// Takes the first dropped path which leads to a folder.
/// </summary>
/// <remarks>
/// A dropped file is rejected instead of being taken as its parent folder. Everything a folder
/// contains is processed, so guessing the parent of a mistakenly dropped file could pull in far
/// more data than the user meant to hand over.
/// </remarks>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
foreach (var path in paths)
var path = DroppedFolder.FirstUsable(paths);
if (path is null)
{
if (!IODirectory.Exists(path))
continue;
this.Logger.LogInformation("The user dropped the directory '{DroppedDirectory}'.", path);
this.InternalDirectoryChanged(path);
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a directory.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFolder.WarningFor(paths)));
return;
}
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a directory.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, this.GetDropWarning(paths)));
}
private string GetDropWarning(List<string> paths)
{
// Naming the actual mistake beats a generic "that did not work". Dropping a file onto a
// folder picker is the likeliest of them:
if (paths.Any(File.Exists))
return T("Please drop a folder, not a file.");
return T("The dropped folder could not be accessed. Please choose it with the folder chooser instead.");
this.Logger.LogInformation("The user dropped the directory '{DroppedDirectory}'.", path);
this.InternalDirectoryChanged(path);
}
}
@@ -3,9 +3,6 @@ using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
// This component has a parameter called File, which would shadow the file system's File class:
using IOFile = System.IO.File;
namespace AIStudio.Components;
public partial class SelectFile : MSGComponentBase
@@ -94,42 +91,18 @@ public partial class SelectFile : MSGComponentBase
/// <summary>
/// Takes the first dropped path which leads to a usable file.
/// </summary>
/// <remarks>
/// This component carries exactly one file, so a multi-selection cannot be honored as a whole.
/// A dropped folder is rejected instead of being read as "the first file inside it": the user
/// was asked for a file, and picking one for them would be a surprise.
/// </remarks>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
foreach (var path in paths)
var path = DroppedFile.FirstUsable(paths, this.Filter);
if (path is null)
{
if (!IOFile.Exists(path))
continue;
if (this.Filter is { Length: > 0 } && !FileTypes.IsAllowedPath(path, this.Filter))
continue;
this.Logger.LogInformation("The user dropped the file '{DroppedFilePath}'.", path);
this.InternalFileChanged(path);
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a file.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFile.WarningFor(paths)));
return;
}
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a file.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, this.GetDropWarning(paths)));
}
private string GetDropWarning(List<string> paths)
{
// Naming the actual mistake beats a generic "that did not work". Dropping a folder onto a
// file picker is the likeliest of them:
if (paths.Any(Directory.Exists))
return T("Please drop a file, not a folder.");
// The file exists, so the file type filter is what turned it down:
if (paths.Any(IOFile.Exists))
return T("Please drop a file with a supported file type.");
return T("The dropped file could not be accessed. Please choose it with the file chooser instead.");
this.Logger.LogInformation("The user dropped the file '{DroppedFilePath}'.", path);
this.InternalFileChanged(path);
}
}