This commit is contained in:
2026-06-02 17:39:58 +02:00
36 changed files with 2520 additions and 463 deletions
+4 -3
View File
@@ -1,17 +1,18 @@
@* Recursive editor row for one folder in the visual builder. *@
@using SharepointToolbox.Web.Core.Models
@inject TranslationSource T
<div class="folder-node" style="margin-left:@(Depth > 1 ? "18px" : "0")">
<div class="flex-row" style="gap:6px">
<span class="text-muted" style="font-family:monospace">📁</span>
<input class="form-input" style="width:auto;flex:1;min-width:160px"
placeholder="Folder name" value="@Node.Name"
placeholder="@T["foldertree.ph.name"]" value="@Node.Name"
@oninput="OnNameInput" />
@if (Depth < FolderNode.MaxDepth)
{
<button class="btn btn-secondary btn-sm" type="button" @onclick="AddChild" title="Add subfolder">+ Sub</button>
<button class="btn btn-secondary btn-sm" type="button" @onclick="AddChild" title="@T["foldertree.btn.addsub.tooltip"]">@T["foldertree.btn.addsub"]</button>
}
<button class="btn btn-danger btn-sm" type="button" @onclick="() => OnRemove.InvokeAsync(Node)" title="Remove">✕</button>
<button class="btn btn-danger btn-sm" type="button" @onclick="() => OnRemove.InvokeAsync(Node)" title="@T["foldertree.btn.remove.tooltip"]">✕</button>
</div>
@foreach (var child in Node.Children)
+4 -3
View File
@@ -1,4 +1,5 @@
@inject ILibraryDiscoveryService LibraryDiscovery
@inject TranslationSource T
@* Library name field with a picker: type a title, or click Browse to load and
choose from the libraries on the selected site. *@
@@ -13,7 +14,7 @@
<button type="button" class="btn btn-secondary"
@onclick="Browse"
disabled="@(Disabled || _loading)">
@(_loading ? "Loading…" : "Browse")
@(_loading ? T["librarypicker.loadingShort"] : T["librarypicker.browse"])
</button>
</div>
@@ -36,7 +37,7 @@
}
@if (_libraries.Count == 0)
{
<div class="text-muted" style="padding:6px">No document libraries found on this site.</div>
<div class="text-muted" style="padding:6px">@T["librarypicker.noLibraries"]</div>
}
</div>
}
@@ -65,7 +66,7 @@
private async Task Browse()
{
_error = string.Empty;
if (string.IsNullOrWhiteSpace(SiteUrl)) { _error = "Select a site first."; return; }
if (string.IsNullOrWhiteSpace(SiteUrl)) { _error = T["librarypicker.selectSiteFirst"]; return; }
// Toggle closed if already showing the list for this site.
if (_open && _loadedForSite == SiteUrl) { _open = false; return; }
+5 -4
View File
@@ -1,5 +1,6 @@
@using Microsoft.AspNetCore.Components.Forms
@using SharepointToolbox.Web.Core.Models
@inject TranslationSource T
@* Reusable logo picker. Reads an image into a base64 LogoData (no disk/blob storage). *@
<div class="logo-upload">
@@ -8,13 +9,13 @@
<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>
<button type="button" class="btn btn-secondary btn-sm" @onclick="Remove">@T["logoupload.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>
<small class="text-muted d-block">@string.Format(T["logoupload.hint"], MaxBytes / 1024)</small>
}
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error mt-8">@_error</div> }
</div>
@@ -36,7 +37,7 @@
if (file.Size > MaxBytes)
{
_error = $"File too large ({file.Size / 1024} KB). Max {MaxBytes / 1024} KB.";
_error = string.Format(T["logoupload.err.toolarge"], file.Size / 1024, MaxBytes / 1024);
return;
}
@@ -51,7 +52,7 @@
}
catch (Exception ex)
{
_error = $"Could not read image: {ex.Message}";
_error = string.Format(T["logoupload.err.read"], ex.Message);
}
}
+6 -4
View File
@@ -1,11 +1,13 @@
@* Dropdown for choosing how multi-site reports are bundled on export. *@
@inject TranslationSource T
@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>
title="@T["mergemode.tooltip"]">
<option value="@ReportMergeMode.SingleMerged">@T["mergemode.opt.singleMerged"]</option>
<option value="@ReportMergeMode.SingleTabbed">@T["mergemode.opt.singleTabbed"]</option>
<option value="@ReportMergeMode.MultipleFiles">@T["mergemode.opt.multipleFiles"]</option>
</select>
}
+5 -3
View File
@@ -1,5 +1,7 @@
@inject TranslationSource T
<div class="no-profile">
<h2>No profile selected</h2>
<p>Select or create a tenant profile to get started.</p>
<a href="/profiles" class="btn btn-primary">Go to Profiles</a>
<h2>@T["noprofile.heading"]</h2>
<p>@T["noprofile.body"]</p>
<a href="/profiles" class="btn btn-primary">@T["noprofile.goto"]</a>
</div>
+4 -3
View File
@@ -1,5 +1,6 @@
@inject IUserSessionService Session
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject TranslationSource T
@implements IDisposable
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@@ -7,7 +8,7 @@
<div class="profile-selector">
<button class="profile-selector-trigger @(Session.HasProfile ? "" : "unset")" @onclick="ToggleAsync">
<span class="profile-selector-icon">🏢</span>
<span class="profile-selector-name">@(Session.CurrentProfile?.Name ?? "Select a profile")</span>
<span class="profile-selector-name">@(Session.CurrentProfile?.Name ?? T["profile.selector.placeholder"])</span>
<span class="profile-selector-caret @(_open ? "open" : "")">▾</span>
</button>
@@ -17,7 +18,7 @@
<div class="profile-selector-menu">
@if (_profiles.Count == 0)
{
<div class="profile-selector-empty">No profiles configured.</div>
<div class="profile-selector-empty">@T["profile.selector.empty"]</div>
}
else
{
@@ -36,7 +37,7 @@
</button>
}
}
<a class="profile-selector-manage" href="/profiles" @onclick="Close">⚙️ Manage profiles</a>
<a class="profile-selector-manage" href="/profiles" @onclick="Close">⚙️ @T["profile.selector.manage"]</a>
</div>
}
</div>
@@ -2,6 +2,7 @@
@inject IUserSessionService Session
@inject ISessionManager SessionManager
@inject NavigationManager Nav
@inject TranslationSource T
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@@ -10,10 +11,10 @@
<div class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="connect-modal-title">
<div class="modal-dialog">
<div class="modal-header">
<h3 id="connect-modal-title">Connect to Microsoft</h3>
<h3 id="connect-modal-title">@T["connect.title"]</h3>
<p class="text-muted">
Authenticate to access <strong>@Session.CurrentProfile?.Name</strong>.
Your session token is stored in your browser only — never saved to disk.
@T["connect.subtitle.prefix"] <strong>@Session.CurrentProfile?.Name</strong>.
@T["connect.token.note"]
</p>
</div>
@@ -23,14 +24,14 @@
}
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="Cancel" disabled="@_connecting">Cancel</button>
<button class="btn btn-secondary" @onclick="Cancel" disabled="@_connecting">@T["btn.cancel"]</button>
<button class="btn btn-primary" @onclick="ConnectAsync" disabled="@_connecting">
@(_connecting ? "Redirecting" : "Connect via Microsoft")
@(_connecting ? T["connect.redirecting"] : T["connect.button"])
</button>
</div>
<p class="text-muted" style="font-size:11px;margin-top:8px;text-align:right">
You will be redirected to Microsoft login. MFA is supported.
@T["connect.redirect.note"]
</p>
</div>
</div>
@@ -54,7 +55,7 @@
private async Task ConnectAsync()
{
var profile = Session.CurrentProfile;
if (profile is null) { _error = "No client profile selected."; return; }
if (profile is null) { _error = T["connect.err.noprofile"]; return; }
_connecting = true;
_error = string.Empty;
+10 -9
View File
@@ -1,13 +1,14 @@
@inject ISiteDiscoveryService SiteDiscovery
@inject TranslationSource T
<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">@(Single ? "Site" : "Sites")</label>
<input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="Filter loaded sites by name or URL…" />
<label class="form-label">@(Single ? T["sitepicker.label.site"] : T["sitepicker.label.sites"])</label>
<input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="@T["sitepicker.ph.filter"]" />
</div>
<button class="btn btn-secondary" @onclick="LoadSites" disabled="@_loading">
@(_loading ? "Loading…" : (_all.Count > 0 ? "Reload sites" : "Load sites"))
@(_loading ? T["sitepicker.status.loadingShort"] : (_all.Count > 0 ? T["sitepicker.btn.reload"] : T["sitepicker.btn.load"]))
</button>
</div>
@@ -21,11 +22,11 @@
<div class="flex-row mt-8" style="gap:12px;align-items:center">
@if (!Single)
{
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">Select all (@Filtered.Count())</button>
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">@string.Format(T["sitepicker.btn.selectAllCount"], Filtered.Count())</button>
}
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">@T["sitepicker.btn.clear"]</button>
<span class="spacer"></span>
<span class="count-badge">@SelectedSites.Count selected</span>
<span class="count-badge">@string.Format(T["sitepicker.status.selectedCount"], SelectedSites.Count)</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)
@@ -45,13 +46,13 @@
}
@if (!Filtered.Any())
{
<div class="text-muted" style="padding:6px">No sites match the filter.</div>
<div class="text-muted" style="padding:6px">@T["sitepicker.empty.noMatch"]</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 @(Single ? "pick one." : "tick the ones to scan.")</div>
<div class="text-muted mt-8" style="font-size:12px">@(Single ? T["sitepicker.hint.loadSingle"] : T["sitepicker.hint.loadMulti"])</div>
}
</div>
@@ -84,7 +85,7 @@
try
{
_all = (await SiteDiscovery.SearchSitesAsync(Profile)).ToList();
if (_all.Count == 0) _error = "No sites returned. The account may lack Sites.Read.All.";
if (_all.Count == 0) _error = T["sitepicker.err.noSites"];
}
catch (Exception ex) { _error = ex.Message; }
finally { _loading = false; }
+2 -1
View File
@@ -1,4 +1,5 @@
@inject IUserContextAccessor UserContext
@inject TranslationSource T
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@@ -17,7 +18,7 @@ else
else
{
<div class="alert alert-info">
You have <strong>read-only</strong> access (Tech-N0). Contact an Admin to request write access.
@T["writeguard.readonly.before"] <strong>@T["writeguard.readonly.emphasis"]</strong> @T["writeguard.readonly.after"]
</div>
}
}