Merge pull request 'Add report logos and configurable folder scan depth' (#2) from feat/report-logos-and-scan-depth into main

Reviewed-on: #2
This commit is contained in:
2026-06-02 15:02:52 +02:00
committed by kawa
26 changed files with 631 additions and 91 deletions
+64
View File
@@ -0,0 +1,64 @@
@using Microsoft.AspNetCore.Components.Forms
@using SharepointToolbox.Web.Core.Models
@* Reusable logo picker. Reads an image into a base64 LogoData (no disk/blob storage). *@
<div class="logo-upload">
@if (Value is not null)
{
<div class="flex-row" style="gap:12px;align-items:center">
<img src="data:@Value.MimeType;base64,@Value.Base64" alt=""
style="max-height:60px;max-width:200px;object-fit:contain;border:1px solid var(--border);border-radius:4px;padding:4px;background:#fff" />
<button type="button" class="btn btn-secondary btn-sm" @onclick="Remove">Remove</button>
</div>
}
else
{
<InputFile OnChange="OnChange" accept="image/png,image/jpeg,image/svg+xml,image/gif" />
<small class="text-muted d-block">PNG, JPEG, SVG or GIF — max @(MaxBytes / 1024) KB.</small>
}
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error mt-8">@_error</div> }
</div>
@code {
[Parameter] public LogoData? Value { get; set; }
[Parameter] public EventCallback<LogoData?> ValueChanged { get; set; }
/// <summary>Upload size cap. Logos are stored inline as base64 in JSON, so keep small.</summary>
[Parameter] public long MaxBytes { get; set; } = 512 * 1024;
private string _error = string.Empty;
private async Task OnChange(InputFileChangeEventArgs e)
{
_error = string.Empty;
var file = e.File;
if (file is null) return;
if (file.Size > MaxBytes)
{
_error = $"File too large ({file.Size / 1024} KB). Max {MaxBytes / 1024} KB.";
return;
}
try
{
using var ms = new MemoryStream();
await file.OpenReadStream(MaxBytes).CopyToAsync(ms);
var mime = string.IsNullOrWhiteSpace(file.ContentType) ? "image/png" : file.ContentType;
var logo = new LogoData { Base64 = Convert.ToBase64String(ms.ToArray()), MimeType = mime };
Value = logo;
await ValueChanged.InvokeAsync(logo);
}
catch (Exception ex)
{
_error = $"Could not read image: {ex.Message}";
}
}
private async Task Remove()
{
Value = null;
_error = string.Empty;
await ValueChanged.InvokeAsync(null);
}
}
+22
View File
@@ -0,0 +1,22 @@
@* Dropdown for choosing how multi-site reports are bundled on export. *@
@if (Visible)
{
<select class="form-select" style="width:auto;font-size:13px" value="@Value" @onchange="OnChange"
title="How to bundle reports when multiple sites are scanned">
<option value="@ReportMergeMode.SingleMerged">One document, no tabs</option>
<option value="@ReportMergeMode.SingleTabbed">One document, tabs (HTML)</option>
<option value="@ReportMergeMode.MultipleFiles">Multiple documents (ZIP)</option>
</select>
}
@code {
[Parameter] public ReportMergeMode Value { get; set; }
[Parameter] public EventCallback<ReportMergeMode> ValueChanged { get; set; }
[Parameter] public bool Visible { get; set; } = true;
private async Task OnChange(ChangeEventArgs e)
{
if (Enum.TryParse<ReportMergeMode>(e.Value?.ToString(), out var mode))
await ValueChanged.InvokeAsync(mode);
}
}
+106
View File
@@ -0,0 +1,106 @@
@inject ISiteDiscoveryService SiteDiscovery
<div class="site-picker">
<div class="flex-row" style="gap:8px;align-items:flex-end">
<div class="form-group" style="flex:1">
<label class="form-label">Sites</label>
<input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="Filter loaded sites by name or URL…" />
</div>
<button class="btn btn-secondary" @onclick="LoadSites" disabled="@_loading">
@(_loading ? "Loading…" : (_all.Count > 0 ? "Reload sites" : "Load sites"))
</button>
</div>
@if (!string.IsNullOrEmpty(_error))
{
<div class="alert alert-error mt-8">@_error</div>
}
@if (_all.Count > 0)
{
<div class="flex-row mt-8" style="gap:12px;align-items:center">
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">Select all (@Filtered.Count())</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button>
<span class="spacer"></span>
<span class="count-badge">@SelectedSites.Count selected</span>
</div>
<div class="site-picker-list" style="max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:4px;padding:4px;margin-top:6px">
@foreach (var s in Filtered)
{
<label style="display:flex;align-items:center;gap:8px;padding:3px 6px;cursor:pointer">
<input type="checkbox" checked="@IsSelected(s)" @onchange="e => Toggle(s, (bool)(e.Value ?? false))" />
<span>@s.Title</span>
<span class="text-muted" style="font-size:11px">@s.Url</span>
</label>
}
@if (!Filtered.Any())
{
<div class="text-muted" style="padding:6px">No sites match the filter.</div>
}
</div>
}
else if (!_loading)
{
<div class="text-muted mt-8" style="font-size:12px">Click “Load sites” to list the tenants SharePoint sites, then tick the ones to scan.</div>
}
</div>
@code {
[Parameter] public TenantProfile Profile { get; set; } = default!;
[Parameter] public List<SiteInfo> SelectedSites { get; set; } = new();
[Parameter] public EventCallback<List<SiteInfo>> SelectedSitesChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
private List<SiteInfo> _all = new();
private string _filter = string.Empty;
private bool _loading;
private string _error = string.Empty;
private IEnumerable<SiteInfo> Filtered =>
string.IsNullOrWhiteSpace(_filter)
? _all
: _all.Where(s =>
s.Title.Contains(_filter, StringComparison.OrdinalIgnoreCase) ||
s.Url.Contains(_filter, StringComparison.OrdinalIgnoreCase));
private bool IsSelected(SiteInfo s) =>
SelectedSites.Any(x => string.Equals(x.Url, s.Url, StringComparison.OrdinalIgnoreCase));
private async Task LoadSites()
{
_loading = true; _error = string.Empty;
try
{
_all = (await SiteDiscovery.SearchSitesAsync(Profile)).ToList();
if (_all.Count == 0) _error = "No sites returned. The account may lack Sites.Read.All.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _loading = false; }
}
private async Task Toggle(SiteInfo s, bool on)
{
if (on)
{
if (!IsSelected(s)) SelectedSites.Add(s);
}
else
{
SelectedSites.RemoveAll(x => string.Equals(x.Url, s.Url, StringComparison.OrdinalIgnoreCase));
}
await SelectedSitesChanged.InvokeAsync(SelectedSites);
}
private async Task SelectAllFiltered()
{
foreach (var s in Filtered)
if (!IsSelected(s)) SelectedSites.Add(s);
await SelectedSitesChanged.InvokeAsync(SelectedSites);
}
private async Task ClearSelection()
{
SelectedSites.Clear();
await SelectedSitesChanged.InvokeAsync(SelectedSites);
}
}