diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor index 7565c3f..590692f 100644 --- a/Components/Layout/MainLayout.razor +++ b/Components/Layout/MainLayout.razor @@ -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 @@
@if (UserContext.IsAuthenticated) { +
+ +
@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); } diff --git a/Components/Pages/Duplicates.razor b/Components/Pages/Duplicates.razor index ea79253..9b054a7 100644 --- a/Components/Pages/Duplicates.razor +++ b/Components/Pages/Duplicates.razor @@ -24,10 +24,7 @@ -
- - -
+
diff --git a/Components/Pages/FileTransfer.razor b/Components/Pages/FileTransfer.razor index a3bf2c5..9e01e8b 100644 --- a/Components/Pages/FileTransfer.razor +++ b/Components/Pages/FileTransfer.razor @@ -16,10 +16,7 @@
Source
-
- - -
+
@@ -31,10 +28,7 @@
Destination
-
- - -
+
diff --git a/Components/Pages/FolderStructure.razor b/Components/Pages/FolderStructure.razor index 413d3e3..cac4e68 100644 --- a/Components/Pages/FolderStructure.razor +++ b/Components/Pages/FolderStructure.razor @@ -17,15 +17,43 @@
-
- - + +
+ +
+ +
+ +
-
- - -
+ + @if (_mode == InputMode.Csv) + { +
+ + +
+ } + else + { +
+ +
+ @foreach (var root in _tree) + { + + } + @if (_tree.Count == 0) + { +

No folders yet. Add a top-level folder to start.

+ } +
+ +
+ } @if (_rows.Count > 0) { @@ -53,14 +81,26 @@ } @code { + private enum InputMode { Csv, Builder } + private InputMode _mode = InputMode.Csv; + private List _sites = new(); private string _libraryTitle = string.Empty; private List> _rows = new(); + private readonly List _tree = new(); private bool _running; private string _status = string.Empty, _error = string.Empty; private int _current, _total; private BulkOperationSummary? _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(r, new List())) + .ToList(); + } + private async Task RunCreate() { _error = string.Empty; _summary = null; _running = true; diff --git a/Components/Pages/Search.razor b/Components/Pages/Search.razor index 9b66d6e..c616426 100644 --- a/Components/Pages/Search.razor +++ b/Components/Pages/Search.razor @@ -42,10 +42,7 @@
-
- - -
+
+ } + +
+ + @foreach (var child in Node.Children) + { + + } +
+ +@code { + [Parameter, EditorRequired] public FolderNode Node { get; set; } = default!; + [Parameter] public int Depth { get; set; } = 1; + [Parameter] public EventCallback 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(); + } +} diff --git a/Components/Shared/LibraryPicker.razor b/Components/Shared/LibraryPicker.razor new file mode 100644 index 0000000..1753233 --- /dev/null +++ b/Components/Shared/LibraryPicker.razor @@ -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. *@ +
+ +
+ + +
+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + + @if (_open) + { +
+ @foreach (var lib in _libraries) + { + + } + @if (_libraries.Count == 0) + { +
No document libraries found on this site.
+ } +
+ } +
+ +@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 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 _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); + } +} diff --git a/Components/Shared/ProfileSelector.razor b/Components/Shared/ProfileSelector.razor new file mode 100644 index 0000000..b4d98f8 --- /dev/null +++ b/Components/Shared/ProfileSelector.razor @@ -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 + +
+ + + @if (_open) + { +
+
+ @if (_profiles.Count == 0) + { +
No profiles configured.
+ } + else + { + @foreach (var p in _profiles) + { + var active = Session.CurrentProfile?.Id == p.Id; + + } + } + βš™οΈ Manage profiles +
+ } +
+ +@code { + private bool _open; + private List _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; +} diff --git a/Core/Models/FolderNode.cs b/Core/Models/FolderNode.cs new file mode 100644 index 0000000..a5d67de --- /dev/null +++ b/Core/Models/FolderNode.cs @@ -0,0 +1,47 @@ +namespace SharepointToolbox.Web.Core.Models; + +/// +/// Editable node for the visual folder-structure builder. SharePoint folders +/// are limited to 4 nesting levels here, matching the CSV template (Level1..Level4). +/// +public class FolderNode +{ + public const int MaxDepth = 4; + + public string Name { get; set; } = string.Empty; + public List Children { get; } = new(); + + /// Flatten the tree into one per leaf path. + public static List Flatten(IEnumerable roots) + { + var rows = new List(); + foreach (var root in roots) + Walk(root, new List(), rows); + return rows; + } + + private static void Walk(FolderNode node, List ancestors, List rows) + { + var name = node.Name.Trim(); + if (string.IsNullOrEmpty(name)) return; + + var path = new List(ancestors) { name }; + + // Only emit a row for leaves; intermediate folders are created as ancestors of the leaf path. + if (node.Children.Count == 0) + { + rows.Add(new FolderStructureRow + { + Level1 = path.ElementAtOrDefault(0) ?? string.Empty, + Level2 = path.ElementAtOrDefault(1) ?? string.Empty, + Level3 = path.ElementAtOrDefault(2) ?? string.Empty, + Level4 = path.ElementAtOrDefault(3) ?? string.Empty, + }); + return; + } + + if (path.Count >= MaxDepth) return; // can't nest deeper + foreach (var child in node.Children) + Walk(child, path, rows); + } +} diff --git a/Infrastructure/OAuth/OAuthEndpoints.cs b/Infrastructure/OAuth/OAuthEndpoints.cs index 21f690b..1ef0729 100644 --- a/Infrastructure/OAuth/OAuthEndpoints.cs +++ b/Infrastructure/OAuth/OAuthEndpoints.cs @@ -124,7 +124,14 @@ public static class OAuthEndpoints var tokenKey = Guid.NewGuid().ToString("N"); flowCache.StoreTokens(tokenKey, tokens); - var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey); + // Pass the profile id back too: the connect flow did a full HTTP redirect that tore + // down the Blazor circuit, dropping the in-memory profile selection. The layout + // re-selects it from this param so the user lands fully connected β€” no second click. + var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, new Dictionary + { + ["token_key"] = tokenKey, + ["profile_id"] = flowState.ProfileId, + }); return Results.Redirect(returnTo); }); diff --git a/Program.cs b/Program.cs index 33a2ae4..4028402 100644 --- a/Program.cs +++ b/Program.cs @@ -151,6 +151,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Services/ILibraryDiscoveryService.cs b/Services/ILibraryDiscoveryService.cs new file mode 100644 index 0000000..1a5deb1 --- /dev/null +++ b/Services/ILibraryDiscoveryService.cs @@ -0,0 +1,20 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +/// +/// Lists the document libraries of a single SharePoint site so users can pick a +/// library from a dropdown instead of typing its title by hand. +/// +public interface ILibraryDiscoveryService +{ + /// + /// Returns the titles of the non-hidden document libraries on + /// , ordered case-insensitively by title. + /// Handles elevation + context creation internally. + /// + Task> ListLibrariesAsync( + TenantProfile profile, + string siteUrl, + CancellationToken ct = default); +} diff --git a/Services/LibraryDiscoveryService.cs b/Services/LibraryDiscoveryService.cs new file mode 100644 index 0000000..f7c9b17 --- /dev/null +++ b/Services/LibraryDiscoveryService.cs @@ -0,0 +1,36 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Helpers; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Services.Session; + +namespace SharepointToolbox.Web.Services; + +public class LibraryDiscoveryService : ILibraryDiscoveryService +{ + private readonly IElevationCoordinator _elevation; + private readonly ISessionManager _sessionManager; + + public LibraryDiscoveryService(IElevationCoordinator elevation, ISessionManager sessionManager) + { + _elevation = elevation; + _sessionManager = sessionManager; + } + + public async Task> ListLibrariesAsync( + TenantProfile profile, string siteUrl, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(siteUrl)) return Array.Empty(); + + return await _elevation.RunAsync(async c => + { + var ctx = await _sessionManager.GetOrCreateContextAsync(siteUrl, profile, c); + ctx.Load(ctx.Web, w => w.Lists.Include(l => l.Title, l => l.Hidden, l => l.BaseType)); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, c); + return (IReadOnlyList)ctx.Web.Lists + .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) + .Select(l => l.Title) + .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) + .ToList(); + }, ct); + } +} diff --git a/wwwroot/app.css b/wwwroot/app.css index dc03f1e..831301d 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -159,6 +159,61 @@ body { .content { flex: 1; overflow-y: auto; padding: 24px 28px; } +/* ── Top bar + profile selector ── */ +.topbar { + position: sticky; top: -24px; z-index: 30; + display: flex; align-items: center; justify-content: flex-end; + gap: 12px; margin: -24px -28px 16px; padding: 12px 28px; + background: var(--page-bg); +} + +.profile-selector { position: relative; } +.profile-selector-trigger { + display: flex; align-items: center; gap: 9px; + padding: 8px 12px; border-radius: var(--radius-md); + border: 1px solid var(--border); background: var(--card-bg); color: var(--text); + font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; + box-shadow: 0 2px 8px rgba(30,30,70,.06); transition: background .15s, border-color .15s; + max-width: 260px; +} +.profile-selector-trigger:hover { background: var(--surface-hover); } +.profile-selector-trigger.unset { color: var(--text-muted); font-weight: 500; } +.profile-selector-icon { font-size: 15px; } +.profile-selector-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.profile-selector-caret { color: var(--text-muted); font-size: 11px; transition: transform .15s; } +.profile-selector-caret.open { transform: rotate(180deg); } + +.profile-selector-backdrop { position: fixed; inset: 0; z-index: 40; } +.profile-selector-menu { + position: absolute; top: calc(100% + 6px); right: 0; z-index: 50; + min-width: 260px; max-width: 320px; padding: 6px; + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-md); box-shadow: 0 12px 34px rgba(30,30,70,.18); +} +.profile-selector-empty { padding: 12px; font-size: 12.5px; color: var(--text-muted); text-align: center; } +.profile-selector-item { + display: flex; align-items: center; gap: 8px; width: 100%; + padding: 9px 11px; border: none; border-radius: var(--radius-sm); + background: none; color: var(--text); cursor: pointer; font-family: inherit; text-align: left; + transition: background .12s; +} +.profile-selector-item:hover { background: var(--surface-hover); } +.profile-selector-item.active { background: var(--surface-hover); } + +.library-picker-item { transition: background .12s; color: var(--text); } +.library-picker-item:hover { background: var(--surface-hover); } +.library-picker-item.active { background: var(--surface-hover); font-weight: 600; } +.profile-selector-item-text { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.profile-selector-item-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.profile-selector-item-url { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.profile-selector-check { color: var(--accent); font-weight: 700; flex-shrink: 0; } +.profile-selector-manage { + display: block; margin-top: 4px; padding: 9px 11px; + border-top: 1px solid var(--border); border-radius: 0 0 var(--radius-sm) var(--radius-sm); + color: var(--text-muted); text-decoration: none; font-size: 12.5px; +} +.profile-selector-manage:hover { background: var(--surface-hover); color: var(--text); } + /* ── Sidebar dark variant ── */ [data-theme="dark"] { --page-bg: #15161c; @@ -322,5 +377,10 @@ body { .feature-card { cursor: pointer; transition: box-shadow .15s, transform .15s; } .feature-card:hover { box-shadow: 0 14px 36px rgba(91, 91, 214, .22); transform: translateY(-2px); } +/* ── Visual folder-structure builder ── */ +.folder-builder { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); } +.folder-node { display: flex; flex-direction: column; gap: 6px; } +.folder-node .folder-node { border-left: 1px solid var(--border); padding-left: 6px; } + /* ── Theme (light-only palette; System resolves via JS) ── */ [data-theme="light"] { color-scheme: light; }