This commit is contained in:
2026-06-02 17:13:26 +02:00
14 changed files with 474 additions and 25 deletions
+16 -1
View File
@@ -7,6 +7,7 @@
@inject NavigationManager Nav
@inject IJSRuntime JS
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.JSInterop
@@ -112,6 +113,9 @@
<main class="content">
@if (UserContext.IsAuthenticated)
{
<header class="topbar">
<ProfileSelector />
</header>
@Body
}
else
@@ -222,9 +226,20 @@
{
await CredStore.SetAsync(tokens);
await SessionManager.ClearAllAsync();
// Re-select the profile that started this connect flow. The forceLoad redirect tore
// down the previous circuit, so the in-memory selection in UserSessionService (scoped)
// was lost. Restoring it here means a successful auth lands the user on a fully
// selected profile instead of forcing a second "Select" click.
if (query.TryGetValue("profile_id", out var profileId) && !string.IsNullOrEmpty(profileId))
{
var profile = (await ProfileRepo.LoadAsync()).FirstOrDefault(p => p.Id == profileId);
if (profile is not null)
Session.SetProfile(profile);
}
}
// Strip token_key from URL bar
// Strip token_key / profile_id from URL bar
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
}
+1 -4
View File
@@ -24,10 +24,7 @@
<option value="Folders">Folders</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Library (optional)</label>
<input class="form-input" @bind="_library" />
</div>
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="Library (optional)" Placeholder="" />
</div>
<div class="flex-row">
<label><input type="checkbox" @bind="_matchSize" /> Match size</label>
+2 -8
View File
@@ -16,10 +16,7 @@
<div class="card-title">Source</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_srcSites" Single="true" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">Source Library</label>
<input class="form-input" @bind="_srcLibrary" placeholder="Shared Documents" />
</div>
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_srcSites.FirstOrDefault()?.Url)" @bind-Library="_srcLibrary" Label="Source Library" />
<div class="form-group">
<label class="form-label">Source Folder (optional)</label>
<input class="form-input" @bind="_srcFolder" placeholder="SubFolder/Path" />
@@ -31,10 +28,7 @@
<div class="card-title">Destination</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_dstSites" Single="true" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">Destination Library</label>
<input class="form-input" @bind="_dstLibrary" placeholder="Shared Documents" />
</div>
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_dstSites.FirstOrDefault()?.Url)" @bind-Library="_dstLibrary" Label="Destination Library" />
<div class="form-group">
<label class="form-label">Destination Folder (optional)</label>
<input class="form-input" @bind="_dstFolder" />
+67 -7
View File
@@ -17,15 +17,43 @@
<div class="card">
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">Library Title</label>
<input class="form-input" @bind="_libraryTitle" placeholder="Shared Documents" />
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_libraryTitle" Label="Library Title" />
</div>
<div class="form-group">
<label class="form-label">Source</label>
<div class="flex-row">
<button class="btn btn-sm @(_mode == InputMode.Csv ? "btn-primary" : "btn-secondary")"
type="button" @onclick="() => SetMode(InputMode.Csv)">Upload CSV</button>
<button class="btn btn-sm @(_mode == InputMode.Builder ? "btn-primary" : "btn-secondary")"
type="button" @onclick="() => SetMode(InputMode.Builder)">Build visually</button>
</div>
</div>
<div class="form-group">
<label class="form-label">CSV File (Level1, Level2, Level3, Level4)</label>
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div>
@if (_mode == InputMode.Csv)
{
<div class="form-group">
<label class="form-label">CSV File (Level1, Level2, Level3, Level4)</label>
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div>
}
else
{
<div class="form-group">
<label class="form-label">Folder Structure</label>
<div class="folder-builder">
@foreach (var root in _tree)
{
<FolderTreeNode Node="root" Depth="1" OnRemove="RemoveRoot" OnChanged="RebuildFromTree" />
}
@if (_tree.Count == 0)
{
<p class="text-muted">No folders yet. Add a top-level folder to start.</p>
}
</div>
<button class="btn btn-secondary btn-sm mt-8" type="button" @onclick="AddRoot">+ Add top-level folder</button>
</div>
}
@if (_rows.Count > 0)
{
@@ -53,14 +81,26 @@
}
@code {
private enum InputMode { Csv, Builder }
private InputMode _mode = InputMode.Csv;
private List<SiteInfo> _sites = new();
private string _libraryTitle = string.Empty;
private List<CsvValidationRow<FolderStructureRow>> _rows = new();
private readonly List<FolderNode> _tree = new();
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private BulkOperationSummary<string>? _summary;
private CancellationTokenSource? _cts;
private void SetMode(InputMode mode)
{
_mode = mode;
_rows.Clear();
_summary = null; _error = string.Empty;
if (mode == InputMode.Builder) RebuildFromTree();
}
private async Task LoadFile(InputFileChangeEventArgs e)
{
_rows.Clear();
@@ -68,6 +108,26 @@
_rows = CsvValidation.ParseAndValidateFolders(stream);
}
private void AddRoot()
{
_tree.Add(new FolderNode());
RebuildFromTree();
}
private void RemoveRoot(FolderNode node)
{
_tree.Remove(node);
RebuildFromTree();
}
// Convert the visual tree into validation rows so the count + Create button share the CSV path.
private void RebuildFromTree()
{
_rows = FolderNode.Flatten(_tree)
.Select(r => new CsvValidationRow<FolderStructureRow>(r, new List<string>()))
.ToList();
}
private async Task RunCreate()
{
_error = string.Empty; _summary = null; _running = true;
+1 -4
View File
@@ -42,10 +42,7 @@
<label class="form-label">Modified by</label>
<input class="form-input" @bind="_modifiedBy" />
</div>
<div class="form-group">
<label class="form-label">Library (optional)</label>
<input class="form-input" @bind="_library" />
</div>
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="Library (optional)" Placeholder="" />
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunSearch" disabled="@_running">
+46
View File
@@ -0,0 +1,46 @@
@* Recursive editor row for one folder in the visual builder. *@
@using SharepointToolbox.Web.Core.Models
<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"
@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-danger btn-sm" type="button" @onclick="() => OnRemove.InvokeAsync(Node)" title="Remove">✕</button>
</div>
@foreach (var child in Node.Children)
{
<FolderTreeNode Node="child" Depth="Depth + 1" OnRemove="RemoveChild" OnChanged="OnChanged" />
}
</div>
@code {
[Parameter, EditorRequired] public FolderNode Node { get; set; } = default!;
[Parameter] public int Depth { get; set; } = 1;
[Parameter] public EventCallback<FolderNode> OnRemove { get; set; }
[Parameter] public EventCallback OnChanged { get; set; }
private async Task OnNameInput(ChangeEventArgs e)
{
Node.Name = e.Value?.ToString() ?? string.Empty;
await OnChanged.InvokeAsync();
}
private async Task AddChild()
{
Node.Children.Add(new FolderNode());
await OnChanged.InvokeAsync();
}
private async Task RemoveChild(FolderNode child)
{
Node.Children.Remove(child);
await OnChanged.InvokeAsync();
}
}
+94
View File
@@ -0,0 +1,94 @@
@inject ILibraryDiscoveryService LibraryDiscovery
@* Library name field with a picker: type a title, or click Browse to load and
choose from the libraries on the selected site. *@
<div class="form-group library-picker">
<label class="form-label">@Label</label>
<div class="flex-row" style="gap:8px;align-items:stretch">
<input class="form-input" style="flex:1"
placeholder="@Placeholder"
value="@Library"
@oninput="OnTextInput"
disabled="@Disabled" />
<button type="button" class="btn btn-secondary"
@onclick="Browse"
disabled="@(Disabled || _loading)">
@(_loading ? "Loading…" : "Browse")
</button>
</div>
@if (!string.IsNullOrEmpty(_error))
{
<div class="alert alert-error mt-8">@_error</div>
}
@if (_open)
{
<div class="library-picker-list" style="max-height:200px;overflow:auto;border:1px solid var(--border);border-radius:4px;padding:4px;margin-top:6px">
@foreach (var lib in _libraries)
{
<button type="button"
class="library-picker-item @(string.Equals(lib, Library, StringComparison.OrdinalIgnoreCase) ? "active" : "")"
style="display:block;width:100%;text-align:left;padding:4px 8px;border:none;background:none;cursor:pointer;border-radius:3px"
@onclick="() => Pick(lib)">
@lib
</button>
}
@if (_libraries.Count == 0)
{
<div class="text-muted" style="padding:6px">No document libraries found on this site.</div>
}
</div>
}
</div>
@code {
[Parameter, EditorRequired] public TenantProfile Profile { get; set; } = default!;
[Parameter] public string? SiteUrl { get; set; }
[Parameter] public string Library { get; set; } = string.Empty;
[Parameter] public EventCallback<string> LibraryChanged { get; set; }
[Parameter] public string Label { get; set; } = "Library";
[Parameter] public string Placeholder { get; set; } = "Shared Documents";
[Parameter] public bool Disabled { get; set; }
private List<string> _libraries = new();
private bool _loading, _open;
private string _error = string.Empty;
private string? _loadedForSite;
private async Task OnTextInput(ChangeEventArgs e)
{
Library = e.Value?.ToString() ?? string.Empty;
await LibraryChanged.InvokeAsync(Library);
}
private async Task Browse()
{
_error = string.Empty;
if (string.IsNullOrWhiteSpace(SiteUrl)) { _error = "Select a site first."; return; }
// Toggle closed if already showing the list for this site.
if (_open && _loadedForSite == SiteUrl) { _open = false; return; }
// Reload when the site changed since the last load.
if (_libraries.Count == 0 || _loadedForSite != SiteUrl)
{
_loading = true;
try
{
_libraries = (await LibraryDiscovery.ListLibrariesAsync(Profile, SiteUrl!)).ToList();
_loadedForSite = SiteUrl;
}
catch (Exception ex) { _error = ex.Message; return; }
finally { _loading = false; }
}
_open = true;
}
private async Task Pick(string lib)
{
Library = lib;
_open = false;
await LibraryChanged.InvokeAsync(Library);
}
}
+75
View File
@@ -0,0 +1,75 @@
@inject IUserSessionService Session
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@implements IDisposable
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
<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-caret @(_open ? "open" : "")">▾</span>
</button>
@if (_open)
{
<div class="profile-selector-backdrop" @onclick="Close"></div>
<div class="profile-selector-menu">
@if (_profiles.Count == 0)
{
<div class="profile-selector-empty">No profiles configured.</div>
}
else
{
@foreach (var p in _profiles)
{
var active = Session.CurrentProfile?.Id == p.Id;
<button class="profile-selector-item @(active ? "active" : "")" @onclick="() => Select(p)">
<span class="profile-selector-item-text">
<span class="profile-selector-item-name">@p.Name</span>
<span class="profile-selector-item-url">@p.TenantUrl</span>
</span>
@if (active)
{
<span class="profile-selector-check">✓</span>
}
</button>
}
}
<a class="profile-selector-manage" href="/profiles" @onclick="Close">⚙️ Manage profiles</a>
</div>
}
</div>
@code {
private bool _open;
private List<TenantProfile> _profiles = new();
protected override async Task OnInitializedAsync()
{
Session.ProfileChanged += OnProfileChanged;
await LoadProfilesAsync();
}
private void OnProfileChanged() => InvokeAsync(StateHasChanged);
private async Task LoadProfilesAsync()
=> _profiles = (await ProfileRepo.LoadAsync()).ToList();
private async Task ToggleAsync()
{
_open = !_open;
// Refresh the list when opening so newly created/edited profiles show up without a reload.
if (_open) await LoadProfilesAsync();
}
private void Close() => _open = false;
private void Select(TenantProfile p)
{
_open = false;
Session.SetProfile(p);
}
public void Dispose() => Session.ProfileChanged -= OnProfileChanged;
}